Feature: Agregar botón para crear webhook de debug automáticamente
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m4s
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m4s
- Agregar botón "Crear Webhook de Debug" en WebhookReceiverSection - Detectar si ya existe un webhook apuntando al receptor de debug - Permitir eliminar el webhook de debug - Incluir todos los eventos disponibles al crear el webhook - También incluye mejoras previas de manejo de media y mensajes
This commit is contained in:
@@ -18,6 +18,32 @@
|
|||||||
<UIcon name="i-lucide-copy" class="w-4 h-4" />
|
<UIcon name="i-lucide-copy" class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto-create webhook button -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<UButton
|
||||||
|
v-if="!debugWebhookExists"
|
||||||
|
:loading="creatingWebhook"
|
||||||
|
variant="soft"
|
||||||
|
color="primary"
|
||||||
|
@click="createDebugWebhook"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-zap" class="w-4 h-4 mr-2" />
|
||||||
|
Crear Webhook de Debug
|
||||||
|
</UButton>
|
||||||
|
<div v-else class="flex items-center gap-2 text-green-400">
|
||||||
|
<UIcon name="i-lucide-check-circle" class="w-4 h-4" />
|
||||||
|
<span class="text-sm">Webhook de debug activo</span>
|
||||||
|
<UButton
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
color="error"
|
||||||
|
@click="deleteDebugWebhook"
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr class="border-[var(--wa-border)]" />
|
<hr class="border-[var(--wa-border)]" />
|
||||||
@@ -111,9 +137,29 @@ interface WebhookEvent {
|
|||||||
headers: Record<string, string | undefined>
|
headers: Record<string, string | undefined>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Webhook {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
events: string[]
|
||||||
|
}
|
||||||
|
|
||||||
const events = ref<WebhookEvent[]>([])
|
const events = ref<WebhookEvent[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const expandedEvents = ref(new Set<string>())
|
const expandedEvents = ref(new Set<string>())
|
||||||
|
const creatingWebhook = ref(false)
|
||||||
|
const debugWebhookId = ref<string | null>(null)
|
||||||
|
|
||||||
|
// All available event types
|
||||||
|
const allEventTypes = [
|
||||||
|
'message.received',
|
||||||
|
'message.sent',
|
||||||
|
'message.status',
|
||||||
|
'instance.connected',
|
||||||
|
'instance.disconnected',
|
||||||
|
'instance.status',
|
||||||
|
'instance.qr'
|
||||||
|
]
|
||||||
|
|
||||||
// Build receiver URL
|
// Build receiver URL
|
||||||
const receiverUrl = computed(() => {
|
const receiverUrl = computed(() => {
|
||||||
@@ -123,6 +169,51 @@ const receiverUrl = computed(() => {
|
|||||||
return '/api/debug/webhook-receiver'
|
return '/api/debug/webhook-receiver'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const debugWebhookExists = computed(() => !!debugWebhookId.value)
|
||||||
|
|
||||||
|
// Check if debug webhook already exists
|
||||||
|
const checkDebugWebhook = async () => {
|
||||||
|
try {
|
||||||
|
const webhooks = await $fetch<Webhook[]>('/api/webhooks')
|
||||||
|
const debugWh = webhooks.find(w => w.url.includes('/api/debug/webhook-receiver'))
|
||||||
|
debugWebhookId.value = debugWh?.id || null
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking webhooks:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create debug webhook
|
||||||
|
const createDebugWebhook = async () => {
|
||||||
|
creatingWebhook.value = true
|
||||||
|
try {
|
||||||
|
const result = await $fetch<{ id: string }>('/api/webhooks', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
name: 'Debug Receiver (Auto)',
|
||||||
|
url: receiverUrl.value,
|
||||||
|
events: allEventTypes,
|
||||||
|
instanceId: null // All instances
|
||||||
|
}
|
||||||
|
})
|
||||||
|
debugWebhookId.value = result.id
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating debug webhook:', error)
|
||||||
|
} finally {
|
||||||
|
creatingWebhook.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete debug webhook
|
||||||
|
const deleteDebugWebhook = async () => {
|
||||||
|
if (!debugWebhookId.value) return
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/webhooks/${debugWebhookId.value}`, { method: 'DELETE' })
|
||||||
|
debugWebhookId.value = null
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting webhook:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fetchEvents = async () => {
|
const fetchEvents = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -172,6 +263,7 @@ const copyToClipboard = async (text: string) => {
|
|||||||
|
|
||||||
// Fetch events on mount and set up polling
|
// Fetch events on mount and set up polling
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
checkDebugWebhook()
|
||||||
fetchEvents()
|
fetchEvents()
|
||||||
|
|
||||||
// Poll every 5 seconds
|
// Poll every 5 seconds
|
||||||
|
|||||||
172
app/components/messages/MediaPreview.vue
Normal file
172
app/components/messages/MediaPreview.vue
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<!-- Files grid -->
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<div
|
||||||
|
v-for="(file, index) in files"
|
||||||
|
:key="index"
|
||||||
|
class="relative group"
|
||||||
|
>
|
||||||
|
<!-- Image preview -->
|
||||||
|
<div
|
||||||
|
v-if="isImage(file)"
|
||||||
|
class="relative"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="getPreviewUrl(file)"
|
||||||
|
class="h-20 w-20 object-cover rounded-lg"
|
||||||
|
/>
|
||||||
|
<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"
|
||||||
|
@click="$emit('remove', index)"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-x" class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Video preview -->
|
||||||
|
<div
|
||||||
|
v-else-if="isVideo(file)"
|
||||||
|
class="relative"
|
||||||
|
>
|
||||||
|
<div class="h-20 w-20 bg-gray-800 rounded-lg flex items-center justify-center">
|
||||||
|
<video
|
||||||
|
:src="getPreviewUrl(file)"
|
||||||
|
class="h-full w-full object-cover rounded-lg"
|
||||||
|
/>
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
|
<UIcon name="i-lucide-play-circle" class="w-8 h-8 text-white/80" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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"
|
||||||
|
@click="$emit('remove', index)"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-x" class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Audio preview -->
|
||||||
|
<div
|
||||||
|
v-else-if="isAudio(file)"
|
||||||
|
class="relative flex items-center gap-2 px-3 py-2 bg-gray-800 rounded-lg"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-music" class="w-5 h-5 text-[var(--wa-green-light)]" />
|
||||||
|
<span class="text-sm text-white truncate max-w-[100px]">{{ file.name }}</span>
|
||||||
|
<button
|
||||||
|
class="w-5 h-5 rounded-full bg-red-500 text-white flex items-center justify-center"
|
||||||
|
@click="$emit('remove', index)"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-x" class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Document preview -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="relative flex items-center gap-2 px-3 py-2 bg-gray-800 rounded-lg"
|
||||||
|
>
|
||||||
|
<UIcon :name="getDocIcon(file)" class="w-5 h-5 text-[var(--wa-blue)]" />
|
||||||
|
<div class="flex flex-col min-w-0">
|
||||||
|
<span class="text-sm text-white truncate max-w-[100px]">{{ file.name }}</span>
|
||||||
|
<span class="text-xs text-gray-400">{{ formatSize(file.size) }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="w-5 h-5 rounded-full bg-red-500 text-white flex items-center justify-center flex-shrink-0"
|
||||||
|
@click="$emit('remove', index)"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-x" class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Caption input -->
|
||||||
|
<div v-if="showCaption && files.length > 0">
|
||||||
|
<UInput
|
||||||
|
:model-value="caption"
|
||||||
|
placeholder="Agregar descripción..."
|
||||||
|
size="sm"
|
||||||
|
class="bg-[var(--wa-bg-light)]"
|
||||||
|
@update:model-value="$emit('update:caption', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
files: File[]
|
||||||
|
caption?: string
|
||||||
|
showCaption?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
caption: '',
|
||||||
|
showCaption: true
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
remove: [index: number]
|
||||||
|
'update:caption': [value: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Preview URL cache
|
||||||
|
const previewUrls = new Map<File, string>()
|
||||||
|
|
||||||
|
const getPreviewUrl = (file: File): string => {
|
||||||
|
if (!previewUrls.has(file)) {
|
||||||
|
previewUrls.set(file, URL.createObjectURL(file))
|
||||||
|
}
|
||||||
|
return previewUrls.get(file)!
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup URLs on unmount
|
||||||
|
onUnmounted(() => {
|
||||||
|
previewUrls.forEach(url => URL.revokeObjectURL(url))
|
||||||
|
previewUrls.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
const isImage = (file: File): boolean => {
|
||||||
|
return file.type.startsWith('image/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const isVideo = (file: File): boolean => {
|
||||||
|
return file.type.startsWith('video/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAudio = (file: File): boolean => {
|
||||||
|
return file.type.startsWith('audio/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDocIcon = (file: File): string => {
|
||||||
|
const type = file.type
|
||||||
|
const name = file.name.toLowerCase()
|
||||||
|
|
||||||
|
if (type.includes('pdf') || name.endsWith('.pdf')) {
|
||||||
|
return 'i-lucide-file-text'
|
||||||
|
}
|
||||||
|
if (type.includes('spreadsheet') || name.match(/\.(xlsx?|csv)$/)) {
|
||||||
|
return 'i-lucide-file-spreadsheet'
|
||||||
|
}
|
||||||
|
if (type.includes('word') || name.match(/\.(docx?)$/)) {
|
||||||
|
return 'i-lucide-file-text'
|
||||||
|
}
|
||||||
|
if (type.includes('presentation') || name.match(/\.(pptx?)$/)) {
|
||||||
|
return 'i-lucide-file-presentation'
|
||||||
|
}
|
||||||
|
if (type.includes('zip') || name.match(/\.(zip|rar|7z)$/)) {
|
||||||
|
return 'i-lucide-file-archive'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'i-lucide-file'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSize = (bytes: number): string => {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,33 +1,136 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col"
|
class="flex flex-col group/message"
|
||||||
:class="message.fromMe ? 'items-end' : 'items-start'"
|
:class="message.fromMe ? 'items-end' : 'items-start'"
|
||||||
>
|
>
|
||||||
<div
|
<!-- Sender name for groups -->
|
||||||
class="max-w-[70%] rounded-lg px-3 py-2"
|
<span
|
||||||
:class="message.fromMe ? 'bubble-out' : 'bubble-in'"
|
v-if="!message.fromMe && isGroup && senderName"
|
||||||
|
class="text-xs font-medium mb-1 ml-1"
|
||||||
|
:style="{ color: senderColor }"
|
||||||
>
|
>
|
||||||
<!-- Message content -->
|
{{ senderName }}
|
||||||
<p class="text-[var(--wa-text)] whitespace-pre-wrap break-words">{{ message.content }}</p>
|
</span>
|
||||||
|
|
||||||
<!-- Image -->
|
<div
|
||||||
<img
|
class="relative max-w-[70%] rounded-lg overflow-hidden"
|
||||||
v-if="message.mediaUrl && message.type === 'image'"
|
:class="bubbleClass"
|
||||||
:src="message.mediaUrl"
|
>
|
||||||
class="rounded-lg max-w-full mt-2"
|
<!-- Quoted message -->
|
||||||
/>
|
<div v-if="message.quoted" class="px-2 pt-2">
|
||||||
|
<MessageQuoted
|
||||||
|
:quoted="message.quoted"
|
||||||
|
@click="$emit('scrollToMessage', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Caption for media -->
|
<!-- Message content based on type -->
|
||||||
<p
|
<div :class="contentPadding">
|
||||||
v-if="message.caption"
|
<!-- Text message -->
|
||||||
class="text-[var(--wa-text)] mt-2"
|
<p
|
||||||
|
v-if="message.type === 'text' && message.content"
|
||||||
|
class="whitespace-pre-wrap break-words"
|
||||||
|
:class="textClass"
|
||||||
|
>
|
||||||
|
{{ message.content }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Image -->
|
||||||
|
<MessageImage
|
||||||
|
v-else-if="message.type === 'image' && message.media"
|
||||||
|
:media="message.media"
|
||||||
|
:instance-id="instanceId"
|
||||||
|
:message-id="message.messageId"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Video -->
|
||||||
|
<MessageVideo
|
||||||
|
v-else-if="message.type === 'video' && message.media"
|
||||||
|
:media="message.media"
|
||||||
|
:instance-id="instanceId"
|
||||||
|
:message-id="message.messageId"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Audio -->
|
||||||
|
<MessageAudio
|
||||||
|
v-else-if="message.type === 'audio' && message.media"
|
||||||
|
:media="message.media"
|
||||||
|
:instance-id="instanceId"
|
||||||
|
:message-id="message.messageId"
|
||||||
|
:from-me="message.fromMe"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Document -->
|
||||||
|
<MessageDocument
|
||||||
|
v-else-if="message.type === 'document' && message.media"
|
||||||
|
:media="message.media"
|
||||||
|
:instance-id="instanceId"
|
||||||
|
:message-id="message.messageId"
|
||||||
|
:from-me="message.fromMe"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Sticker -->
|
||||||
|
<MessageSticker
|
||||||
|
v-else-if="message.type === 'sticker' && message.media"
|
||||||
|
:media="message.media"
|
||||||
|
:instance-id="instanceId"
|
||||||
|
:message-id="message.messageId"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Contact -->
|
||||||
|
<MessageContact
|
||||||
|
v-else-if="message.type === 'contact' && message.contact"
|
||||||
|
:contact="message.contact"
|
||||||
|
:from-me="message.fromMe"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Location -->
|
||||||
|
<MessageLocation
|
||||||
|
v-else-if="message.type === 'location' && message.location"
|
||||||
|
:location="message.location"
|
||||||
|
:from-me="message.fromMe"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Unknown/unsupported type -->
|
||||||
|
<div
|
||||||
|
v-else-if="message.type === 'unknown' || !message.content"
|
||||||
|
class="flex items-center gap-2 text-sm"
|
||||||
|
:class="message.fromMe ? 'text-white/70' : 'text-[var(--wa-text-muted)]'"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-help-circle" class="w-4 h-4" />
|
||||||
|
<span>Mensaje no soportado</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Caption for media -->
|
||||||
|
<p
|
||||||
|
v-if="message.caption"
|
||||||
|
class="mt-2 whitespace-pre-wrap break-words"
|
||||||
|
:class="textClass"
|
||||||
|
>
|
||||||
|
{{ message.caption }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reactions -->
|
||||||
|
<div
|
||||||
|
v-if="message.reactions && message.reactions.length > 0"
|
||||||
|
class="flex flex-wrap gap-1 px-2 pb-1"
|
||||||
>
|
>
|
||||||
{{ message.caption }}
|
<span
|
||||||
</p>
|
v-for="(reaction, i) in groupedReactions"
|
||||||
|
:key="i"
|
||||||
|
class="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-xs"
|
||||||
|
:class="message.fromMe ? 'bg-white/20' : 'bg-[var(--wa-bg-light)]'"
|
||||||
|
:title="reaction.reactors.join(', ')"
|
||||||
|
>
|
||||||
|
<span>{{ reaction.emoji }}</span>
|
||||||
|
<span v-if="reaction.count > 1" class="text-[10px] opacity-70">{{ reaction.count }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer with time and status -->
|
||||||
<div class="flex items-center justify-end gap-1 mt-1">
|
<div class="flex items-center justify-end gap-1 px-2 pb-1.5" :class="{ 'pt-1': !message.content && !message.caption }">
|
||||||
<span class="text-xs text-[var(--wa-text-muted)]">
|
<span class="text-[11px]" :class="message.fromMe ? 'text-white/60' : 'text-[var(--wa-text-muted)]'">
|
||||||
{{ formatTime(message.timestamp) }}
|
{{ formatTime(message.timestamp) }}
|
||||||
</span>
|
</span>
|
||||||
<UIcon
|
<UIcon
|
||||||
@@ -36,13 +139,36 @@
|
|||||||
class="w-4 h-4"
|
class="w-4 h-4"
|
||||||
:class="statusColor"
|
:class="statusColor"
|
||||||
/>
|
/>
|
||||||
<!-- Debug button -->
|
</div>
|
||||||
|
|
||||||
|
<!-- Action buttons on hover -->
|
||||||
|
<div
|
||||||
|
class="absolute top-1 opacity-0 group-hover/message:opacity-100 transition-opacity flex gap-0.5"
|
||||||
|
:class="message.fromMe ? 'left-1' : 'right-1'"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
@click="showDebug = !showDebug"
|
class="p-1 rounded-full hover:bg-black/10"
|
||||||
class="ml-1 text-xs text-[var(--wa-text-muted)] hover:text-[var(--wa-blue)] opacity-50 hover:opacity-100"
|
:class="message.fromMe ? 'text-white/60 hover:text-white' : 'text-[var(--wa-text-muted)] hover:text-[var(--wa-text)]'"
|
||||||
title="Debug info"
|
title="Responder"
|
||||||
|
@click="$emit('reply', message)"
|
||||||
>
|
>
|
||||||
<UIcon name="i-lucide-bug" class="w-3 h-3" />
|
<UIcon name="i-lucide-reply" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-1 rounded-full hover:bg-black/10"
|
||||||
|
:class="message.fromMe ? 'text-white/60 hover:text-white' : 'text-[var(--wa-text-muted)] hover:text-[var(--wa-text)]'"
|
||||||
|
title="Reaccionar"
|
||||||
|
@click="$emit('react', message)"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-smile-plus" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-1 rounded-full hover:bg-black/10"
|
||||||
|
:class="message.fromMe ? 'text-white/60 hover:text-white' : 'text-[var(--wa-text-muted)] hover:text-[var(--wa-text)]'"
|
||||||
|
title="Debug"
|
||||||
|
@click="showDebug = !showDebug"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-bug" class="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,9 +181,9 @@
|
|||||||
<div class="flex items-center justify-between mb-1">
|
<div class="flex items-center justify-between mb-1">
|
||||||
<span class="text-gray-400">Message:</span>
|
<span class="text-gray-400">Message:</span>
|
||||||
<button
|
<button
|
||||||
@click="copyToClipboard(JSON.stringify(message, null, 2))"
|
|
||||||
class="px-2 py-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-300"
|
class="px-2 py-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-300"
|
||||||
title="Copiar al portapapeles"
|
title="Copiar al portapapeles"
|
||||||
|
@click="copyToClipboard(JSON.stringify(message, null, 2))"
|
||||||
>
|
>
|
||||||
<UIcon name="i-lucide-copy" class="w-3 h-3" />
|
<UIcon name="i-lucide-copy" class="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
@@ -68,39 +194,86 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
interface Message {
|
import type { Message, ReactionInfo } from '~/types/message'
|
||||||
id: string
|
import { stringToColor } from '~/types/message'
|
||||||
content: string
|
|
||||||
type: 'text' | 'image' | 'video' | 'document' | 'audio'
|
// Sub-components
|
||||||
mediaUrl?: string
|
import MessageQuoted from './content/MessageQuoted.vue'
|
||||||
caption?: string
|
import MessageImage from './content/MessageImage.vue'
|
||||||
fromMe: boolean
|
import MessageVideo from './content/MessageVideo.vue'
|
||||||
timestamp: Date
|
import MessageAudio from './content/MessageAudio.vue'
|
||||||
status: 'pending' | 'sent' | 'delivered' | 'read' | 'failed'
|
import MessageDocument from './content/MessageDocument.vue'
|
||||||
}
|
import MessageSticker from './content/MessageSticker.vue'
|
||||||
|
import MessageContact from './content/MessageContact.vue'
|
||||||
|
import MessageLocation from './content/MessageLocation.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: Message
|
message: Message
|
||||||
|
instanceId: string
|
||||||
|
isGroup?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
isGroup: false
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
reply: [message: Message]
|
||||||
|
react: [message: Message]
|
||||||
|
scrollToMessage: [id: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
const showDebug = ref(false)
|
const showDebug = ref(false)
|
||||||
|
|
||||||
const copyToClipboard = async (text: string) => {
|
// Computed properties
|
||||||
try {
|
const senderName = computed(() => {
|
||||||
await navigator.clipboard.writeText(text)
|
if (props.message.fromMe) return null
|
||||||
} catch (err) {
|
return props.message.pushName || props.message.participant?.split('@')[0] || null
|
||||||
console.error('Failed to copy:', err)
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatTime = (date: Date) => {
|
const senderColor = computed(() => {
|
||||||
return new Date(date).toLocaleTimeString('es-AR', {
|
return stringToColor(props.message.participant || props.message.fromJid)
|
||||||
hour: '2-digit',
|
})
|
||||||
minute: '2-digit'
|
|
||||||
})
|
const bubbleClass = computed(() => {
|
||||||
}
|
const base = props.message.fromMe ? 'bubble-out' : 'bubble-in'
|
||||||
|
|
||||||
|
// Stickers don't have background
|
||||||
|
if (props.message.type === 'sticker') {
|
||||||
|
return 'bg-transparent'
|
||||||
|
}
|
||||||
|
|
||||||
|
return base
|
||||||
|
})
|
||||||
|
|
||||||
|
const contentPadding = computed(() => {
|
||||||
|
// Media messages have less padding
|
||||||
|
if (['image', 'video', 'sticker'].includes(props.message.type)) {
|
||||||
|
return 'p-1'
|
||||||
|
}
|
||||||
|
return 'px-3 py-2'
|
||||||
|
})
|
||||||
|
|
||||||
|
const textClass = computed(() => {
|
||||||
|
return props.message.fromMe ? 'text-white' : 'text-[var(--wa-text)]'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Group reactions by emoji
|
||||||
|
const groupedReactions = computed(() => {
|
||||||
|
if (!props.message.reactions) return []
|
||||||
|
|
||||||
|
const groups: Record<string, { emoji: string; count: number; reactors: string[] }> = {}
|
||||||
|
|
||||||
|
for (const reaction of props.message.reactions) {
|
||||||
|
if (!groups[reaction.emoji]) {
|
||||||
|
groups[reaction.emoji] = { emoji: reaction.emoji, count: 0, reactors: [] }
|
||||||
|
}
|
||||||
|
groups[reaction.emoji].count++
|
||||||
|
groups[reaction.emoji].reactors.push(reaction.reactorName || reaction.reactorJid)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(groups)
|
||||||
|
})
|
||||||
|
|
||||||
const statusIcon = computed(() => {
|
const statusIcon = computed(() => {
|
||||||
const icons: Record<string, string> = {
|
const icons: Record<string, string> = {
|
||||||
@@ -116,6 +289,34 @@ const statusIcon = computed(() => {
|
|||||||
const statusColor = computed(() => {
|
const statusColor = computed(() => {
|
||||||
if (props.message.status === 'read') return 'text-[var(--wa-blue)]'
|
if (props.message.status === 'read') return 'text-[var(--wa-blue)]'
|
||||||
if (props.message.status === 'failed') return 'text-red-500'
|
if (props.message.status === 'failed') return 'text-red-500'
|
||||||
return 'text-[var(--wa-text-muted)]'
|
return props.message.fromMe ? 'text-white/60' : 'text-[var(--wa-text-muted)]'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const formatTime = (date: Date) => {
|
||||||
|
return new Date(date).toLocaleTimeString('es-AR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bubble-in {
|
||||||
|
background-color: var(--wa-surface);
|
||||||
|
border-top-left-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-out {
|
||||||
|
background-color: var(--wa-green-dark);
|
||||||
|
border-top-right-radius: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,45 +1,301 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-end gap-2">
|
<div class="space-y-2">
|
||||||
<!-- Attachment button -->
|
<!-- Reply preview -->
|
||||||
<UButton
|
<div
|
||||||
variant="ghost"
|
v-if="replyingTo"
|
||||||
icon="i-lucide-paperclip"
|
class="flex items-center gap-2 p-2 rounded-lg bg-[var(--wa-bg-light)]"
|
||||||
class="text-[var(--wa-text-muted)]"
|
>
|
||||||
|
<div class="w-1 h-8 rounded-full bg-[var(--wa-green-light)]" />
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-xs font-medium text-[var(--wa-green-light)]">
|
||||||
|
{{ replyingTo.fromMe ? 'Tú' : (replyingTo.pushName || 'Mensaje') }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-[var(--wa-text-muted)] truncate">
|
||||||
|
{{ replyingTo.content || getTypePlaceholder(replyingTo.type) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="p-1 rounded-full hover:bg-[var(--wa-border)]"
|
||||||
|
@click="$emit('cancelReply')"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-x" class="w-4 h-4 text-[var(--wa-text-muted)]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Media preview -->
|
||||||
|
<MediaPreview
|
||||||
|
v-if="selectedFiles.length > 0"
|
||||||
|
:files="selectedFiles"
|
||||||
|
:caption="caption"
|
||||||
|
@remove="removeFile"
|
||||||
|
@update:caption="caption = $event"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Text input -->
|
<!-- Recording indicator -->
|
||||||
<div class="flex-1">
|
<div
|
||||||
<UTextarea
|
v-if="isRecording"
|
||||||
v-model="message"
|
class="flex items-center gap-3 p-3 rounded-lg bg-red-900/30"
|
||||||
placeholder="Escribe un mensaje..."
|
>
|
||||||
:rows="1"
|
<div class="w-3 h-3 rounded-full bg-red-500 animate-pulse" />
|
||||||
autoresize
|
<span class="text-red-400 font-medium">{{ formatDuration(recordingDuration) }}</span>
|
||||||
:maxrows="5"
|
<div class="flex-1" />
|
||||||
class="bg-[var(--wa-bg-light)]"
|
<button
|
||||||
@keydown.enter.exact.prevent="handleSend"
|
class="p-2 rounded-full bg-red-500/20 text-red-400 hover:bg-red-500/30"
|
||||||
|
@click="cancelRecording"
|
||||||
|
title="Cancelar"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-trash-2" class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-2 rounded-full bg-green-500 text-white hover:bg-green-600"
|
||||||
|
@click="sendVoiceMessage"
|
||||||
|
title="Enviar"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-send" class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main input area -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex items-end gap-2"
|
||||||
|
:class="{ 'ring-2 ring-[var(--wa-green-light)] rounded-lg': isDragging }"
|
||||||
|
@dragover.prevent="isDragging = true"
|
||||||
|
@dragleave="isDragging = false"
|
||||||
|
@drop.prevent="handleDrop"
|
||||||
|
>
|
||||||
|
<!-- Attachment button -->
|
||||||
|
<UDropdown :items="attachmentMenuItems" :popper="{ placement: 'top-start' }">
|
||||||
|
<UButton
|
||||||
|
variant="ghost"
|
||||||
|
icon="i-lucide-paperclip"
|
||||||
|
class="text-[var(--wa-text-muted)]"
|
||||||
|
/>
|
||||||
|
</UDropdown>
|
||||||
|
|
||||||
|
<!-- Hidden file inputs -->
|
||||||
|
<input
|
||||||
|
ref="imageInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
@change="handleFileSelect($event, 'image')"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref="videoInput"
|
||||||
|
type="file"
|
||||||
|
accept="video/*"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
@change="handleFileSelect($event, 'video')"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref="audioInput"
|
||||||
|
type="file"
|
||||||
|
accept="audio/*"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
@change="handleFileSelect($event, 'audio')"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref="documentInput"
|
||||||
|
type="file"
|
||||||
|
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
@change="handleFileSelect($event, 'document')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Text input -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<UTextarea
|
||||||
|
v-model="message"
|
||||||
|
placeholder="Escribe un mensaje..."
|
||||||
|
:rows="1"
|
||||||
|
autoresize
|
||||||
|
:maxrows="5"
|
||||||
|
class="bg-[var(--wa-bg-light)]"
|
||||||
|
@keydown.enter.exact.prevent="handleSend"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Send / Mic button -->
|
||||||
|
<UButton
|
||||||
|
v-if="hasContent"
|
||||||
|
icon="i-lucide-send"
|
||||||
|
@click="handleSend"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
v-else
|
||||||
|
icon="i-lucide-mic"
|
||||||
|
:disabled="!audioRecorderSupported"
|
||||||
|
@click="startRecording"
|
||||||
|
:title="audioRecorderSupported ? 'Grabar nota de voz' : 'Grabación no soportada'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Send button -->
|
<!-- Drag hint -->
|
||||||
<UButton
|
<div
|
||||||
:icon="message.trim() ? 'i-lucide-send' : 'i-lucide-mic'"
|
v-if="isDragging"
|
||||||
:disabled="!message.trim()"
|
class="absolute inset-0 flex items-center justify-center bg-black/50 rounded-lg pointer-events-none z-10"
|
||||||
@click="handleSend"
|
>
|
||||||
/>
|
<div class="text-center">
|
||||||
|
<UIcon name="i-lucide-upload" class="w-12 h-12 text-white mb-2" />
|
||||||
|
<p class="text-white font-medium">Suelta los archivos aquí</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { Message, MessageType } from '~/types/message'
|
||||||
|
import { getMessageTypePlaceholder } from '~/types/message'
|
||||||
|
import { useAudioRecorder } from '~/composables/useAudioRecorder'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
replyingTo?: Message | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
replyingTo: null
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
send: [content: string]
|
send: [content: string, files: File[], caption: string, quotedId?: string]
|
||||||
|
sendVoice: [audioFile: File]
|
||||||
|
cancelReply: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// Refs for file inputs
|
||||||
|
const imageInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const videoInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const audioInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const documentInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
// State
|
||||||
const message = ref('')
|
const message = ref('')
|
||||||
|
const selectedFiles = ref<File[]>([])
|
||||||
|
const caption = ref('')
|
||||||
|
const isDragging = ref(false)
|
||||||
|
|
||||||
|
// Audio recorder
|
||||||
|
const {
|
||||||
|
isRecording,
|
||||||
|
duration: recordingDuration,
|
||||||
|
isSupported: audioRecorderSupported,
|
||||||
|
startRecording: startAudioRecording,
|
||||||
|
stopRecording,
|
||||||
|
cancelRecording: cancelAudioRecording,
|
||||||
|
getAudioFile,
|
||||||
|
clearAudio
|
||||||
|
} = useAudioRecorder()
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const hasContent = computed(() => {
|
||||||
|
return message.value.trim().length > 0 || selectedFiles.value.length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Attachment menu
|
||||||
|
const attachmentMenuItems = [
|
||||||
|
[{
|
||||||
|
label: 'Imagen',
|
||||||
|
icon: 'i-lucide-image',
|
||||||
|
click: () => imageInput.value?.click()
|
||||||
|
}],
|
||||||
|
[{
|
||||||
|
label: 'Video',
|
||||||
|
icon: 'i-lucide-video',
|
||||||
|
click: () => videoInput.value?.click()
|
||||||
|
}],
|
||||||
|
[{
|
||||||
|
label: 'Audio',
|
||||||
|
icon: 'i-lucide-music',
|
||||||
|
click: () => audioInput.value?.click()
|
||||||
|
}],
|
||||||
|
[{
|
||||||
|
label: 'Documento',
|
||||||
|
icon: 'i-lucide-file',
|
||||||
|
click: () => documentInput.value?.click()
|
||||||
|
}]
|
||||||
|
]
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const handleFileSelect = (event: Event, type: string) => {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
if (input.files) {
|
||||||
|
addFiles(Array.from(input.files))
|
||||||
|
input.value = '' // Reset input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = (event: DragEvent) => {
|
||||||
|
isDragging.value = false
|
||||||
|
if (event.dataTransfer?.files) {
|
||||||
|
addFiles(Array.from(event.dataTransfer.files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addFiles = (files: File[]) => {
|
||||||
|
// Limit to 10 files
|
||||||
|
const remaining = 10 - selectedFiles.value.length
|
||||||
|
const toAdd = files.slice(0, remaining)
|
||||||
|
selectedFiles.value.push(...toAdd)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFile = (index: number) => {
|
||||||
|
selectedFiles.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
if (!message.value.trim()) return
|
if (!hasContent.value) return
|
||||||
|
|
||||||
emit('send', message.value)
|
emit(
|
||||||
|
'send',
|
||||||
|
message.value.trim(),
|
||||||
|
[...selectedFiles.value],
|
||||||
|
caption.value,
|
||||||
|
props.replyingTo?.messageId
|
||||||
|
)
|
||||||
|
|
||||||
|
// Clear state
|
||||||
message.value = ''
|
message.value = ''
|
||||||
|
selectedFiles.value = []
|
||||||
|
caption.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const startRecording = async () => {
|
||||||
|
const success = await startAudioRecording()
|
||||||
|
if (!success) {
|
||||||
|
// Show error toast
|
||||||
|
console.error('Failed to start recording')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelRecording = () => {
|
||||||
|
cancelAudioRecording()
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendVoiceMessage = () => {
|
||||||
|
stopRecording()
|
||||||
|
|
||||||
|
// Wait a bit for the blob to be ready
|
||||||
|
setTimeout(() => {
|
||||||
|
const audioFile = getAudioFile()
|
||||||
|
if (audioFile) {
|
||||||
|
emit('sendVoice', audioFile)
|
||||||
|
clearAudio()
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number): string => {
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = seconds % 60
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypePlaceholder = (type: MessageType): string => {
|
||||||
|
return getMessageTypePlaceholder(type)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
178
app/components/messages/content/MessageAudio.vue
Normal file
178
app/components/messages/content/MessageAudio.vue
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-3 min-w-[200px] max-w-[280px]">
|
||||||
|
<!-- Play/Pause button -->
|
||||||
|
<button
|
||||||
|
class="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 transition-colors"
|
||||||
|
:class="fromMe ? 'bg-white/20 hover:bg-white/30' : 'bg-[var(--wa-green-light)] hover:bg-[var(--wa-green-dark)]'"
|
||||||
|
:disabled="loading || error"
|
||||||
|
@click="togglePlay"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
v-if="loading"
|
||||||
|
name="i-lucide-loader-2"
|
||||||
|
class="w-5 h-5 animate-spin"
|
||||||
|
:class="fromMe ? 'text-white' : 'text-white'"
|
||||||
|
/>
|
||||||
|
<UIcon
|
||||||
|
v-else-if="error"
|
||||||
|
name="i-lucide-alert-circle"
|
||||||
|
class="w-5 h-5"
|
||||||
|
:class="fromMe ? 'text-white' : 'text-white'"
|
||||||
|
/>
|
||||||
|
<UIcon
|
||||||
|
v-else
|
||||||
|
:name="isPlaying ? 'i-lucide-pause' : 'i-lucide-play'"
|
||||||
|
class="w-5 h-5"
|
||||||
|
:class="[fromMe ? 'text-white' : 'text-white', !isPlaying && 'ml-0.5']"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<!-- Waveform / Progress bar -->
|
||||||
|
<div class="relative h-6 flex items-center">
|
||||||
|
<!-- Waveform bars -->
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-[2px] w-full h-full"
|
||||||
|
@click="seekTo"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(bar, i) in waveformBars"
|
||||||
|
:key="i"
|
||||||
|
class="flex-1 rounded-full transition-colors cursor-pointer"
|
||||||
|
:class="i < playedBars ? (fromMe ? 'bg-white' : 'bg-[var(--wa-green-dark)]') : (fromMe ? 'bg-white/40' : 'bg-[var(--wa-text-muted)]/40')"
|
||||||
|
:style="{ height: `${bar}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Duration -->
|
||||||
|
<div class="flex justify-between mt-1">
|
||||||
|
<span class="text-xs" :class="fromMe ? 'text-white/70' : 'text-[var(--wa-text-muted)]'">
|
||||||
|
{{ currentTimeFormatted }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs" :class="fromMe ? 'text-white/70' : 'text-[var(--wa-text-muted)]'">
|
||||||
|
{{ durationFormatted }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PTT indicator (mic icon for voice messages) -->
|
||||||
|
<div
|
||||||
|
v-if="media.isPtt"
|
||||||
|
class="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0"
|
||||||
|
:class="fromMe ? 'bg-white/20' : 'bg-[var(--wa-bg-light)]'"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-mic"
|
||||||
|
class="w-4 h-4"
|
||||||
|
:class="fromMe ? 'text-white' : 'text-[var(--wa-text-muted)]'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden audio element -->
|
||||||
|
<audio
|
||||||
|
ref="audioRef"
|
||||||
|
:src="audioUrl"
|
||||||
|
preload="metadata"
|
||||||
|
@loadedmetadata="onLoadedMetadata"
|
||||||
|
@timeupdate="onTimeUpdate"
|
||||||
|
@play="isPlaying = true"
|
||||||
|
@pause="isPlaying = false"
|
||||||
|
@ended="onEnded"
|
||||||
|
@error="onError"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { MediaInfo } from '~/types/message'
|
||||||
|
import { formatDuration } from '~/types/message'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
media: MediaInfo
|
||||||
|
instanceId: string
|
||||||
|
messageId: string
|
||||||
|
fromMe?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
fromMe: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const audioRef = ref<HTMLAudioElement | null>(null)
|
||||||
|
const isPlaying = ref(false)
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref(false)
|
||||||
|
const currentTime = ref(0)
|
||||||
|
const duration = ref(props.media.duration || 0)
|
||||||
|
|
||||||
|
const audioUrl = computed(() => {
|
||||||
|
if (props.media.url) return props.media.url
|
||||||
|
return `/api/media/${props.instanceId}/${props.messageId}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// Generar barras de waveform (fake si no hay datos reales)
|
||||||
|
const waveformBars = computed(() => {
|
||||||
|
if (props.media.waveform && props.media.waveform.length > 0) {
|
||||||
|
// Normalizar waveform real a porcentajes
|
||||||
|
const max = Math.max(...props.media.waveform)
|
||||||
|
return props.media.waveform.slice(0, 40).map(v => Math.max(15, (v / max) * 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generar waveform fake basado en messageId para que sea consistente
|
||||||
|
const seed = props.messageId.split('').reduce((a, b) => a + b.charCodeAt(0), 0)
|
||||||
|
return Array.from({ length: 40 }, (_, i) => {
|
||||||
|
const val = Math.sin(seed + i * 0.5) * 0.5 + 0.5
|
||||||
|
return 15 + val * 85
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const playedBars = computed(() => {
|
||||||
|
if (duration.value === 0) return 0
|
||||||
|
return Math.floor((currentTime.value / duration.value) * waveformBars.value.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentTimeFormatted = computed(() => formatDuration(currentTime.value))
|
||||||
|
const durationFormatted = computed(() => formatDuration(duration.value))
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (!audioRef.value || error.value) return
|
||||||
|
|
||||||
|
if (isPlaying.value) {
|
||||||
|
audioRef.value.pause()
|
||||||
|
} else {
|
||||||
|
audioRef.value.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seekTo = (e: MouseEvent) => {
|
||||||
|
if (!audioRef.value || duration.value === 0) return
|
||||||
|
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||||
|
const percent = (e.clientX - rect.left) / rect.width
|
||||||
|
audioRef.value.currentTime = percent * duration.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const onLoadedMetadata = () => {
|
||||||
|
loading.value = false
|
||||||
|
if (audioRef.value && !props.media.duration) {
|
||||||
|
duration.value = audioRef.value.duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTimeUpdate = () => {
|
||||||
|
if (audioRef.value) {
|
||||||
|
currentTime.value = audioRef.value.currentTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEnded = () => {
|
||||||
|
isPlaying.value = false
|
||||||
|
currentTime.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const onError = () => {
|
||||||
|
loading.value = false
|
||||||
|
error.value = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
152
app/components/messages/content/MessageContact.vue
Normal file
152
app/components/messages/content/MessageContact.vue
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3 p-3 rounded-lg min-w-[200px] max-w-[280px]"
|
||||||
|
:class="fromMe ? 'bg-white/10' : 'bg-[var(--wa-bg-light)]'"
|
||||||
|
>
|
||||||
|
<!-- Avatar placeholder -->
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0"
|
||||||
|
:class="fromMe ? 'bg-white/20' : 'bg-[var(--wa-border)]'"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-user"
|
||||||
|
class="w-6 h-6"
|
||||||
|
:class="fromMe ? 'text-white' : 'text-[var(--wa-text-muted)]'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact info -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
class="font-medium truncate"
|
||||||
|
:class="fromMe ? 'text-white' : 'text-[var(--wa-text)]'"
|
||||||
|
>
|
||||||
|
{{ contact.displayName }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="primaryPhone"
|
||||||
|
class="text-sm mt-0.5 truncate"
|
||||||
|
:class="fromMe ? 'text-white/70' : 'text-[var(--wa-text-muted)]'"
|
||||||
|
>
|
||||||
|
{{ primaryPhone }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<div
|
||||||
|
class="flex gap-2 mt-2"
|
||||||
|
:class="fromMe ? 'justify-end' : 'justify-start'"
|
||||||
|
>
|
||||||
|
<UButton
|
||||||
|
size="xs"
|
||||||
|
:variant="fromMe ? 'soft' : 'outline'"
|
||||||
|
:class="fromMe ? 'text-white border-white/30' : ''"
|
||||||
|
@click="viewContact"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-eye" class="w-3 h-3 mr-1" />
|
||||||
|
Ver
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
v-if="primaryPhone"
|
||||||
|
size="xs"
|
||||||
|
:variant="fromMe ? 'soft' : 'outline'"
|
||||||
|
:class="fromMe ? 'text-white border-white/30' : ''"
|
||||||
|
@click="sendMessage"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-message-circle" class="w-3 h-3 mr-1" />
|
||||||
|
Mensaje
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact details modal -->
|
||||||
|
<UModal v-model="showDetails">
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-12 h-12 rounded-full bg-[var(--wa-green-light)] flex items-center justify-center">
|
||||||
|
<UIcon name="i-lucide-user" class="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-lg">{{ contact.displayName }}</h3>
|
||||||
|
<p class="text-sm text-[var(--wa-text-muted)]">Contacto compartido</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div v-for="(phone, i) in contact.phones" :key="i" class="flex items-center gap-3">
|
||||||
|
<UIcon name="i-lucide-phone" class="w-5 h-5 text-[var(--wa-text-muted)]" />
|
||||||
|
<a
|
||||||
|
:href="`tel:${phone}`"
|
||||||
|
class="text-[var(--wa-blue)] hover:underline"
|
||||||
|
>
|
||||||
|
{{ phone }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!contact.phones?.length" class="text-[var(--wa-text-muted)] text-sm">
|
||||||
|
No hay números de teléfono disponibles
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<UButton variant="ghost" @click="showDetails = false">
|
||||||
|
Cerrar
|
||||||
|
</UButton>
|
||||||
|
<UButton v-if="primaryPhone" @click="copyPhone">
|
||||||
|
<UIcon name="i-lucide-copy" class="w-4 h-4 mr-1" />
|
||||||
|
Copiar número
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
</UModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ContactInfo } from '~/types/message'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
contact: ContactInfo
|
||||||
|
fromMe?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
fromMe: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const showDetails = ref(false)
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const primaryPhone = computed(() => {
|
||||||
|
if (props.contact.phones && props.contact.phones.length > 0) {
|
||||||
|
return props.contact.phones[0]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
const viewContact = () => {
|
||||||
|
showDetails.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendMessage = () => {
|
||||||
|
if (primaryPhone.value) {
|
||||||
|
// Formatear número para WhatsApp
|
||||||
|
const number = primaryPhone.value.replace(/\D/g, '')
|
||||||
|
window.open(`https://wa.me/${number}`, '_blank')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyPhone = async () => {
|
||||||
|
if (primaryPhone.value) {
|
||||||
|
await navigator.clipboard.writeText(primaryPhone.value)
|
||||||
|
toast.add({
|
||||||
|
title: 'Número copiado',
|
||||||
|
icon: 'i-lucide-check',
|
||||||
|
color: 'green'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
213
app/components/messages/content/MessageDocument.vue
Normal file
213
app/components/messages/content/MessageDocument.vue
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-colors min-w-[200px] max-w-[280px]"
|
||||||
|
:class="fromMe ? 'bg-white/10 hover:bg-white/20' : 'bg-[var(--wa-bg-light)] hover:bg-[var(--wa-border)]'"
|
||||||
|
@click="downloadFile"
|
||||||
|
>
|
||||||
|
<!-- File icon -->
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||||
|
:class="iconBgClass"
|
||||||
|
>
|
||||||
|
<UIcon :name="fileIcon" class="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File info -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
class="text-sm font-medium truncate"
|
||||||
|
:class="fromMe ? 'text-white' : 'text-[var(--wa-text)]'"
|
||||||
|
>
|
||||||
|
{{ fileName }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="text-xs mt-0.5"
|
||||||
|
:class="fromMe ? 'text-white/70' : 'text-[var(--wa-text-muted)]'"
|
||||||
|
>
|
||||||
|
{{ fileInfo }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Download indicator -->
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<UIcon
|
||||||
|
v-if="loading"
|
||||||
|
name="i-lucide-loader-2"
|
||||||
|
class="w-5 h-5 animate-spin"
|
||||||
|
:class="fromMe ? 'text-white/70' : 'text-[var(--wa-text-muted)]'"
|
||||||
|
/>
|
||||||
|
<UIcon
|
||||||
|
v-else
|
||||||
|
name="i-lucide-download"
|
||||||
|
class="w-5 h-5"
|
||||||
|
:class="fromMe ? 'text-white/70' : 'text-[var(--wa-text-muted)]'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { MediaInfo } from '~/types/message'
|
||||||
|
import { formatFileSize } from '~/types/message'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
media: MediaInfo
|
||||||
|
instanceId: string
|
||||||
|
messageId: string
|
||||||
|
fromMe?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
fromMe: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const fileName = computed(() => {
|
||||||
|
return props.media.filename || 'Documento'
|
||||||
|
})
|
||||||
|
|
||||||
|
const fileInfo = computed(() => {
|
||||||
|
const parts: string[] = []
|
||||||
|
|
||||||
|
// Extensión
|
||||||
|
const ext = getFileExtension(props.media.filename || '', props.media.mimetype)
|
||||||
|
if (ext) parts.push(ext.toUpperCase())
|
||||||
|
|
||||||
|
// Tamaño
|
||||||
|
if (props.media.filesize) {
|
||||||
|
parts.push(formatFileSize(props.media.filesize))
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' • ') || 'Documento'
|
||||||
|
})
|
||||||
|
|
||||||
|
const fileIcon = computed(() => {
|
||||||
|
const mimetype = props.media.mimetype || ''
|
||||||
|
const filename = props.media.filename || ''
|
||||||
|
|
||||||
|
// PDF
|
||||||
|
if (mimetype.includes('pdf') || filename.endsWith('.pdf')) {
|
||||||
|
return 'i-lucide-file-text'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hojas de cálculo
|
||||||
|
if (mimetype.includes('spreadsheet') || mimetype.includes('excel') ||
|
||||||
|
filename.match(/\.(xlsx?|csv|ods)$/i)) {
|
||||||
|
return 'i-lucide-file-spreadsheet'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Documentos de texto
|
||||||
|
if (mimetype.includes('word') || mimetype.includes('document') ||
|
||||||
|
filename.match(/\.(docx?|odt|rtf)$/i)) {
|
||||||
|
return 'i-lucide-file-text'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presentaciones
|
||||||
|
if (mimetype.includes('presentation') || mimetype.includes('powerpoint') ||
|
||||||
|
filename.match(/\.(pptx?|odp)$/i)) {
|
||||||
|
return 'i-lucide-file-presentation'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Código
|
||||||
|
if (mimetype.includes('javascript') || mimetype.includes('json') ||
|
||||||
|
mimetype.includes('html') || mimetype.includes('css') ||
|
||||||
|
filename.match(/\.(js|ts|json|html|css|py|java|c|cpp|go|rs)$/i)) {
|
||||||
|
return 'i-lucide-file-code'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comprimidos
|
||||||
|
if (mimetype.includes('zip') || mimetype.includes('rar') ||
|
||||||
|
mimetype.includes('tar') || mimetype.includes('gzip') ||
|
||||||
|
filename.match(/\.(zip|rar|7z|tar|gz)$/i)) {
|
||||||
|
return 'i-lucide-file-archive'
|
||||||
|
}
|
||||||
|
|
||||||
|
// APK
|
||||||
|
if (filename.endsWith('.apk')) {
|
||||||
|
return 'i-lucide-smartphone'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'i-lucide-file'
|
||||||
|
})
|
||||||
|
|
||||||
|
const iconBgClass = computed(() => {
|
||||||
|
const mimetype = props.media.mimetype || ''
|
||||||
|
const filename = props.media.filename || ''
|
||||||
|
|
||||||
|
// PDF - rojo
|
||||||
|
if (mimetype.includes('pdf') || filename.endsWith('.pdf')) {
|
||||||
|
return 'bg-red-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Excel - verde
|
||||||
|
if (mimetype.includes('spreadsheet') || mimetype.includes('excel') ||
|
||||||
|
filename.match(/\.(xlsx?|csv)$/i)) {
|
||||||
|
return 'bg-green-600'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Word - azul
|
||||||
|
if (mimetype.includes('word') || filename.match(/\.(docx?)$/i)) {
|
||||||
|
return 'bg-blue-600'
|
||||||
|
}
|
||||||
|
|
||||||
|
// PowerPoint - naranja
|
||||||
|
if (mimetype.includes('presentation') || filename.match(/\.(pptx?)$/i)) {
|
||||||
|
return 'bg-orange-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comprimidos - amarillo
|
||||||
|
if (mimetype.includes('zip') || mimetype.includes('rar') ||
|
||||||
|
filename.match(/\.(zip|rar|7z|tar|gz)$/i)) {
|
||||||
|
return 'bg-yellow-600'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Código - morado
|
||||||
|
if (mimetype.includes('javascript') || mimetype.includes('json') ||
|
||||||
|
filename.match(/\.(js|ts|json|py|java)$/i)) {
|
||||||
|
return 'bg-purple-600'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'bg-gray-500'
|
||||||
|
})
|
||||||
|
|
||||||
|
function getFileExtension(filename: string, mimetype?: string): string {
|
||||||
|
// Intentar extraer de filename
|
||||||
|
const match = filename.match(/\.([a-zA-Z0-9]+)$/)
|
||||||
|
if (match) return match[1]
|
||||||
|
|
||||||
|
// Intentar extraer de mimetype
|
||||||
|
if (mimetype) {
|
||||||
|
const parts = mimetype.split('/')
|
||||||
|
if (parts.length > 1) {
|
||||||
|
const subtype = parts[1]
|
||||||
|
// Limpiar formatos como "vnd.openxmlformats-officedocument..."
|
||||||
|
if (subtype.includes('spreadsheet')) return 'xlsx'
|
||||||
|
if (subtype.includes('document')) return 'docx'
|
||||||
|
if (subtype.includes('presentation')) return 'pptx'
|
||||||
|
return subtype
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadFile = async () => {
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = props.media.url || `/api/media/${props.instanceId}/${props.messageId}`
|
||||||
|
|
||||||
|
// Crear link de descarga
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = props.media.filename || 'documento'
|
||||||
|
a.target = '_blank'
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
174
app/components/messages/content/MessageImage.vue
Normal file
174
app/components/messages/content/MessageImage.vue
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative group">
|
||||||
|
<!-- Image -->
|
||||||
|
<img
|
||||||
|
v-if="!error"
|
||||||
|
:src="imageUrl"
|
||||||
|
:alt="'Imagen'"
|
||||||
|
class="rounded-lg max-w-full cursor-pointer"
|
||||||
|
:class="[
|
||||||
|
{ 'max-h-80': !expanded },
|
||||||
|
{ 'animate-pulse bg-[var(--wa-bg-light)]': loading }
|
||||||
|
]"
|
||||||
|
@load="onLoad"
|
||||||
|
@error="onError"
|
||||||
|
@click="toggleExpand"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div
|
||||||
|
v-if="error"
|
||||||
|
class="flex flex-col items-center justify-center p-8 bg-[var(--wa-bg-light)] rounded-lg min-w-[200px]"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-image-off" class="w-12 h-12 text-[var(--wa-text-muted)] mb-2" />
|
||||||
|
<p class="text-sm text-[var(--wa-text-muted)]">No se pudo cargar la imagen</p>
|
||||||
|
<UButton
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
class="mt-2"
|
||||||
|
@click="retryLoad"
|
||||||
|
>
|
||||||
|
Reintentar
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading placeholder with thumbnail -->
|
||||||
|
<div
|
||||||
|
v-if="loading && !error"
|
||||||
|
class="relative"
|
||||||
|
>
|
||||||
|
<!-- Blur thumbnail as placeholder -->
|
||||||
|
<img
|
||||||
|
v-if="media.thumbnail"
|
||||||
|
:src="`data:image/jpeg;base64,${media.thumbnail}`"
|
||||||
|
class="rounded-lg max-w-full max-h-80 blur-sm"
|
||||||
|
:style="{ width: `${media.width || 200}px`, height: `${media.height || 200}px`, objectFit: 'cover' }"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="rounded-lg bg-[var(--wa-bg-light)]"
|
||||||
|
:style="{ width: `${media.width || 200}px`, height: `${media.height || 200}px` }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Loading spinner overlay -->
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
|
<UIcon name="i-lucide-loader-2" class="w-8 h-8 text-white animate-spin drop-shadow-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- View once indicator -->
|
||||||
|
<div
|
||||||
|
v-if="media.isViewOnce && !error"
|
||||||
|
class="absolute top-2 left-2 px-2 py-1 rounded bg-black/50 text-white text-xs flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-eye" class="w-3 h-3" />
|
||||||
|
<span>Vista única</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expand/download buttons on hover -->
|
||||||
|
<div
|
||||||
|
v-if="!error && !loading"
|
||||||
|
class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="p-1.5 rounded-full bg-black/50 text-white hover:bg-black/70"
|
||||||
|
@click.stop="downloadImage"
|
||||||
|
title="Descargar"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-download" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-1.5 rounded-full bg-black/50 text-white hover:bg-black/70"
|
||||||
|
@click.stop="openFullscreen"
|
||||||
|
title="Ver en pantalla completa"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-maximize" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fullscreen modal -->
|
||||||
|
<UModal v-model="showFullscreen" :ui="{ width: 'max-w-[95vw]' }">
|
||||||
|
<div class="relative bg-black flex items-center justify-center min-h-[50vh]">
|
||||||
|
<img
|
||||||
|
:src="imageUrl"
|
||||||
|
:alt="'Imagen'"
|
||||||
|
class="max-w-full max-h-[90vh] object-contain"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="absolute top-4 right-4 p-2 rounded-full bg-black/50 text-white hover:bg-black/70"
|
||||||
|
@click="showFullscreen = false"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-x" class="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="absolute bottom-4 right-4 p-2 rounded-full bg-black/50 text-white hover:bg-black/70"
|
||||||
|
@click="downloadImage"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-download" class="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</UModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { MediaInfo } from '~/types/message'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
media: MediaInfo
|
||||||
|
instanceId: string
|
||||||
|
messageId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref(false)
|
||||||
|
const expanded = ref(false)
|
||||||
|
const showFullscreen = ref(false)
|
||||||
|
|
||||||
|
const imageUrl = computed(() => {
|
||||||
|
if (props.media.url) return props.media.url
|
||||||
|
return `/api/media/${props.instanceId}/${props.messageId}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const onLoad = () => {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onError = () => {
|
||||||
|
loading.value = false
|
||||||
|
error.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryLoad = () => {
|
||||||
|
error.value = false
|
||||||
|
loading.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleExpand = () => {
|
||||||
|
expanded.value = !expanded.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const openFullscreen = () => {
|
||||||
|
showFullscreen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadImage = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(imageUrl.value)
|
||||||
|
const blob = await response.blob()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `imagen-${props.messageId}.jpg`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error downloading image:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
118
app/components/messages/content/MessageLocation.vue
Normal file
118
app/components/messages/content/MessageLocation.vue
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="rounded-lg overflow-hidden cursor-pointer max-w-[280px]"
|
||||||
|
@click="openInMaps"
|
||||||
|
>
|
||||||
|
<!-- Map preview image -->
|
||||||
|
<div class="relative">
|
||||||
|
<img
|
||||||
|
:src="mapImageUrl"
|
||||||
|
:alt="location.name || 'Ubicación'"
|
||||||
|
class="w-full h-[150px] object-cover"
|
||||||
|
@error="mapError = true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Fallback si la imagen falla -->
|
||||||
|
<div
|
||||||
|
v-if="mapError"
|
||||||
|
class="w-full h-[150px] flex items-center justify-center bg-[var(--wa-bg-light)]"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-map" class="w-12 h-12 text-[var(--wa-text-muted)]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pin overlay -->
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<div class="w-8 h-8 -mt-4">
|
||||||
|
<UIcon name="i-lucide-map-pin" class="w-8 h-8 text-red-500 drop-shadow-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Location info -->
|
||||||
|
<div
|
||||||
|
class="p-3"
|
||||||
|
:class="fromMe ? 'bg-white/10' : 'bg-[var(--wa-bg-light)]'"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
v-if="location.name"
|
||||||
|
class="font-medium truncate"
|
||||||
|
:class="fromMe ? 'text-white' : 'text-[var(--wa-text)]'"
|
||||||
|
>
|
||||||
|
{{ location.name }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="location.address"
|
||||||
|
class="text-sm mt-0.5 line-clamp-2"
|
||||||
|
:class="fromMe ? 'text-white/70' : 'text-[var(--wa-text-muted)]'"
|
||||||
|
>
|
||||||
|
{{ location.address }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="!location.name && !location.address"
|
||||||
|
class="text-sm"
|
||||||
|
:class="fromMe ? 'text-white/70' : 'text-[var(--wa-text-muted)]'"
|
||||||
|
>
|
||||||
|
{{ formatCoordinates(location.latitude, location.longitude) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Open in maps hint -->
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-1 mt-2 text-xs"
|
||||||
|
:class="fromMe ? 'text-white/50' : 'text-[var(--wa-text-muted)]'"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-external-link" class="w-3 h-3" />
|
||||||
|
<span>Abrir en Google Maps</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { LocationInfo } from '~/types/message'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
location: LocationInfo
|
||||||
|
fromMe?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
fromMe: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapError = ref(false)
|
||||||
|
|
||||||
|
// URL de imagen estática de mapa (usando OpenStreetMap tiles)
|
||||||
|
const mapImageUrl = computed(() => {
|
||||||
|
const { latitude, longitude } = props.location
|
||||||
|
const zoom = 15
|
||||||
|
|
||||||
|
// Usar Google Static Maps API (requiere API key en producción)
|
||||||
|
// Por ahora usamos un servicio gratuito alternativo
|
||||||
|
return `https://static-maps.yandex.ru/1.x/?ll=${longitude},${latitude}&z=${zoom}&l=map&size=300,150&pt=${longitude},${latitude},pm2rdm`
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapsUrl = computed(() => {
|
||||||
|
const { latitude, longitude, name } = props.location
|
||||||
|
const query = name ? encodeURIComponent(name) : `${latitude},${longitude}`
|
||||||
|
return `https://www.google.com/maps/search/?api=1&query=${query}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const openInMaps = () => {
|
||||||
|
window.open(mapsUrl.value, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCoordinates = (lat: number, lng: number): string => {
|
||||||
|
const latDir = lat >= 0 ? 'N' : 'S'
|
||||||
|
const lngDir = lng >= 0 ? 'E' : 'W'
|
||||||
|
return `${Math.abs(lat).toFixed(4)}° ${latDir}, ${Math.abs(lng).toFixed(4)}° ${lngDir}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
78
app/components/messages/content/MessageQuoted.vue
Normal file
78
app/components/messages/content/MessageQuoted.vue
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex rounded-lg overflow-hidden cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
:class="quoted.fromMe ? 'bg-[var(--wa-green-light)]/20' : 'bg-[var(--wa-bg-light)]'"
|
||||||
|
@click="$emit('click', quoted.id)"
|
||||||
|
>
|
||||||
|
<!-- Barra de color lateral -->
|
||||||
|
<div
|
||||||
|
class="w-1 flex-shrink-0"
|
||||||
|
:style="{ backgroundColor: barColor }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex-1 px-2 py-1.5 min-w-0">
|
||||||
|
<!-- Nombre del autor -->
|
||||||
|
<p
|
||||||
|
class="text-xs font-medium truncate"
|
||||||
|
:style="{ color: barColor }"
|
||||||
|
>
|
||||||
|
{{ authorName }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Contenido según tipo -->
|
||||||
|
<div class="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<!-- Thumbnail si es media -->
|
||||||
|
<img
|
||||||
|
v-if="quoted.media?.thumbnail"
|
||||||
|
:src="`data:image/jpeg;base64,${quoted.media.thumbnail}`"
|
||||||
|
class="w-10 h-10 rounded object-cover flex-shrink-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Icono de tipo si no es texto -->
|
||||||
|
<UIcon
|
||||||
|
v-if="quoted.type !== 'text' && !quoted.media?.thumbnail"
|
||||||
|
:name="typeIcon"
|
||||||
|
class="w-4 h-4 text-[var(--wa-text-muted)] flex-shrink-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Texto o placeholder -->
|
||||||
|
<p class="text-sm text-[var(--wa-text-muted)] truncate">
|
||||||
|
{{ displayText }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { QuotedMessage } from '~/types/message'
|
||||||
|
import { getMessageTypePlaceholder, getMessageTypeIcon, stringToColor } from '~/types/message'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
quoted: QuotedMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
click: [id: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const authorName = computed(() => {
|
||||||
|
if (props.quoted.fromMe) return 'Tú'
|
||||||
|
return props.quoted.participantName || props.quoted.participant?.split('@')[0] || 'Desconocido'
|
||||||
|
})
|
||||||
|
|
||||||
|
const barColor = computed(() => {
|
||||||
|
if (props.quoted.fromMe) return 'var(--wa-green-dark)'
|
||||||
|
return stringToColor(props.quoted.participant || '')
|
||||||
|
})
|
||||||
|
|
||||||
|
const typeIcon = computed(() => getMessageTypeIcon(props.quoted.type))
|
||||||
|
|
||||||
|
const displayText = computed(() => {
|
||||||
|
if (props.quoted.content) return props.quoted.content
|
||||||
|
if (props.quoted.media?.filename) return props.quoted.media.filename
|
||||||
|
return getMessageTypePlaceholder(props.quoted.type)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
56
app/components/messages/content/MessageSticker.vue
Normal file
56
app/components/messages/content/MessageSticker.vue
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative inline-block">
|
||||||
|
<!-- Sticker image -->
|
||||||
|
<img
|
||||||
|
v-if="!error"
|
||||||
|
:src="stickerUrl"
|
||||||
|
:alt="'Sticker'"
|
||||||
|
class="max-w-[180px] max-h-[180px] object-contain"
|
||||||
|
:class="{ 'animate-pulse bg-[var(--wa-bg-light)] rounded-lg': loading }"
|
||||||
|
@load="loading = false"
|
||||||
|
@error="onError"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div
|
||||||
|
v-if="error"
|
||||||
|
class="w-[120px] h-[120px] flex flex-col items-center justify-center bg-[var(--wa-bg-light)] rounded-lg"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-image-off" class="w-8 h-8 text-[var(--wa-text-muted)]" />
|
||||||
|
<span class="text-xs text-[var(--wa-text-muted)] mt-1">Sticker</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading placeholder -->
|
||||||
|
<div
|
||||||
|
v-if="loading && !error"
|
||||||
|
class="w-[120px] h-[120px] flex items-center justify-center bg-[var(--wa-bg-light)] rounded-lg"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-loader-2" class="w-6 h-6 text-[var(--wa-text-muted)] animate-spin" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { MediaInfo } from '~/types/message'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
media: MediaInfo
|
||||||
|
instanceId: string
|
||||||
|
messageId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref(false)
|
||||||
|
|
||||||
|
const stickerUrl = computed(() => {
|
||||||
|
if (props.media.url) return props.media.url
|
||||||
|
return `/api/media/${props.instanceId}/${props.messageId}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const onError = () => {
|
||||||
|
loading.value = false
|
||||||
|
error.value = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
150
app/components/messages/content/MessageVideo.vue
Normal file
150
app/components/messages/content/MessageVideo.vue
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative group">
|
||||||
|
<!-- Video player -->
|
||||||
|
<video
|
||||||
|
v-if="!error"
|
||||||
|
ref="videoRef"
|
||||||
|
:src="videoUrl"
|
||||||
|
:poster="posterUrl"
|
||||||
|
class="rounded-lg max-w-full"
|
||||||
|
:class="{ 'max-h-80': !fullscreen }"
|
||||||
|
:controls="isPlaying"
|
||||||
|
preload="metadata"
|
||||||
|
@loadedmetadata="onLoadedMetadata"
|
||||||
|
@play="isPlaying = true"
|
||||||
|
@pause="isPlaying = false"
|
||||||
|
@ended="isPlaying = false"
|
||||||
|
@error="onError"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Placeholder con play button cuando no está reproduciendo -->
|
||||||
|
<div
|
||||||
|
v-if="!isPlaying && !error"
|
||||||
|
class="absolute inset-0 flex items-center justify-center cursor-pointer bg-black/20 rounded-lg"
|
||||||
|
@click="playVideo"
|
||||||
|
>
|
||||||
|
<!-- Play button -->
|
||||||
|
<div class="w-14 h-14 rounded-full bg-black/50 flex items-center justify-center backdrop-blur-sm">
|
||||||
|
<UIcon name="i-lucide-play" class="w-8 h-8 text-white ml-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Duration badge -->
|
||||||
|
<div
|
||||||
|
v-if="duration"
|
||||||
|
class="absolute bottom-2 left-2 px-1.5 py-0.5 rounded bg-black/60 text-white text-xs"
|
||||||
|
>
|
||||||
|
{{ formattedDuration }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div
|
||||||
|
v-if="error"
|
||||||
|
class="flex flex-col items-center justify-center p-8 bg-[var(--wa-bg-light)] rounded-lg"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-video-off" class="w-12 h-12 text-[var(--wa-text-muted)] mb-2" />
|
||||||
|
<p class="text-sm text-[var(--wa-text-muted)]">No se pudo cargar el video</p>
|
||||||
|
<UButton
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
class="mt-2"
|
||||||
|
@click="retryLoad"
|
||||||
|
>
|
||||||
|
Reintentar
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading overlay -->
|
||||||
|
<div
|
||||||
|
v-if="loading"
|
||||||
|
class="absolute inset-0 flex items-center justify-center bg-[var(--wa-bg-light)] rounded-lg"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-loader-2" class="w-8 h-8 text-[var(--wa-text-muted)] animate-spin" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fullscreen button -->
|
||||||
|
<button
|
||||||
|
v-if="!error && isPlaying"
|
||||||
|
class="absolute top-2 right-2 p-1.5 rounded-full bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
@click="toggleFullscreen"
|
||||||
|
>
|
||||||
|
<UIcon :name="fullscreen ? 'i-lucide-minimize' : 'i-lucide-maximize'" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { MediaInfo } from '~/types/message'
|
||||||
|
import { formatDuration } from '~/types/message'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
media: MediaInfo
|
||||||
|
instanceId: string
|
||||||
|
messageId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const videoRef = ref<HTMLVideoElement | null>(null)
|
||||||
|
const isPlaying = ref(false)
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref(false)
|
||||||
|
const fullscreen = ref(false)
|
||||||
|
const duration = ref(props.media.duration || 0)
|
||||||
|
|
||||||
|
const videoUrl = computed(() => {
|
||||||
|
if (props.media.url) return props.media.url
|
||||||
|
return `/api/media/${props.instanceId}/${props.messageId}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const posterUrl = computed(() => {
|
||||||
|
if (props.media.thumbnail) {
|
||||||
|
return `data:image/jpeg;base64,${props.media.thumbnail}`
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const formattedDuration = computed(() => formatDuration(duration.value))
|
||||||
|
|
||||||
|
const playVideo = () => {
|
||||||
|
if (videoRef.value) {
|
||||||
|
videoRef.value.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onLoadedMetadata = () => {
|
||||||
|
loading.value = false
|
||||||
|
if (videoRef.value && !props.media.duration) {
|
||||||
|
duration.value = videoRef.value.duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onError = () => {
|
||||||
|
loading.value = false
|
||||||
|
error.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryLoad = () => {
|
||||||
|
error.value = false
|
||||||
|
loading.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleFullscreen = async () => {
|
||||||
|
if (!videoRef.value) return
|
||||||
|
|
||||||
|
if (!fullscreen.value) {
|
||||||
|
await videoRef.value.requestFullscreen()
|
||||||
|
fullscreen.value = true
|
||||||
|
} else {
|
||||||
|
await document.exitFullscreen()
|
||||||
|
fullscreen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escuchar cambios de fullscreen
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('fullscreenchange', () => {
|
||||||
|
fullscreen.value = !!document.fullscreenElement
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
245
app/composables/useAudioRecorder.ts
Normal file
245
app/composables/useAudioRecorder.ts
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
/**
|
||||||
|
* Composable for recording audio (voice messages)
|
||||||
|
* Uses MediaRecorder API to capture audio from microphone
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface AudioRecorderState {
|
||||||
|
isRecording: boolean
|
||||||
|
isPaused: boolean
|
||||||
|
duration: number
|
||||||
|
audioBlob: Blob | null
|
||||||
|
audioUrl: string | null
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAudioRecorder() {
|
||||||
|
const state = reactive<AudioRecorderState>({
|
||||||
|
isRecording: false,
|
||||||
|
isPaused: false,
|
||||||
|
duration: 0,
|
||||||
|
audioBlob: null,
|
||||||
|
audioUrl: null,
|
||||||
|
error: null
|
||||||
|
})
|
||||||
|
|
||||||
|
let mediaRecorder: MediaRecorder | null = null
|
||||||
|
let audioChunks: Blob[] = []
|
||||||
|
let stream: MediaStream | null = null
|
||||||
|
let durationInterval: NodeJS.Timeout | null = null
|
||||||
|
let startTime: number = 0
|
||||||
|
|
||||||
|
// Check if browser supports audio recording
|
||||||
|
const isSupported = computed(() => {
|
||||||
|
return typeof navigator !== 'undefined' &&
|
||||||
|
navigator.mediaDevices &&
|
||||||
|
typeof navigator.mediaDevices.getUserMedia === 'function'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get preferred MIME type for recording
|
||||||
|
const getMimeType = (): string => {
|
||||||
|
const types = [
|
||||||
|
'audio/webm;codecs=opus',
|
||||||
|
'audio/webm',
|
||||||
|
'audio/ogg;codecs=opus',
|
||||||
|
'audio/ogg',
|
||||||
|
'audio/mp4',
|
||||||
|
'audio/mpeg'
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const type of types) {
|
||||||
|
if (MediaRecorder.isTypeSupported(type)) {
|
||||||
|
return type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'audio/webm' // fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start recording
|
||||||
|
const startRecording = async (): Promise<boolean> => {
|
||||||
|
if (!isSupported.value) {
|
||||||
|
state.error = 'La grabación de audio no está soportada en este navegador'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Reset state
|
||||||
|
state.error = null
|
||||||
|
state.audioBlob = null
|
||||||
|
state.audioUrl = null
|
||||||
|
audioChunks = []
|
||||||
|
|
||||||
|
// Request microphone access
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
autoGainControl: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create MediaRecorder
|
||||||
|
const mimeType = getMimeType()
|
||||||
|
mediaRecorder = new MediaRecorder(stream, { mimeType })
|
||||||
|
|
||||||
|
// Handle data available
|
||||||
|
mediaRecorder.ondataavailable = (event) => {
|
||||||
|
if (event.data.size > 0) {
|
||||||
|
audioChunks.push(event.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle recording stop
|
||||||
|
mediaRecorder.onstop = () => {
|
||||||
|
// Create blob from chunks
|
||||||
|
const mimeType = mediaRecorder?.mimeType || 'audio/webm'
|
||||||
|
state.audioBlob = new Blob(audioChunks, { type: mimeType })
|
||||||
|
state.audioUrl = URL.createObjectURL(state.audioBlob)
|
||||||
|
|
||||||
|
// Stop all tracks
|
||||||
|
if (stream) {
|
||||||
|
stream.getTracks().forEach(track => track.stop())
|
||||||
|
stream = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear interval
|
||||||
|
if (durationInterval) {
|
||||||
|
clearInterval(durationInterval)
|
||||||
|
durationInterval = null
|
||||||
|
}
|
||||||
|
|
||||||
|
state.isRecording = false
|
||||||
|
state.isPaused = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle errors
|
||||||
|
mediaRecorder.onerror = (event: any) => {
|
||||||
|
console.error('MediaRecorder error:', event.error)
|
||||||
|
state.error = 'Error durante la grabación'
|
||||||
|
stopRecording()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start recording
|
||||||
|
mediaRecorder.start(100) // Collect data every 100ms
|
||||||
|
state.isRecording = true
|
||||||
|
startTime = Date.now()
|
||||||
|
|
||||||
|
// Update duration every 100ms
|
||||||
|
durationInterval = setInterval(() => {
|
||||||
|
if (state.isRecording && !state.isPaused) {
|
||||||
|
state.duration = Math.floor((Date.now() - startTime) / 1000)
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error starting recording:', error)
|
||||||
|
|
||||||
|
if (error.name === 'NotAllowedError') {
|
||||||
|
state.error = 'Permiso de micrófono denegado'
|
||||||
|
} else if (error.name === 'NotFoundError') {
|
||||||
|
state.error = 'No se encontró micrófono'
|
||||||
|
} else {
|
||||||
|
state.error = 'Error al iniciar la grabación'
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop recording and finalize
|
||||||
|
const stopRecording = (): void => {
|
||||||
|
if (mediaRecorder && state.isRecording) {
|
||||||
|
mediaRecorder.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel recording without saving
|
||||||
|
const cancelRecording = (): void => {
|
||||||
|
if (mediaRecorder && state.isRecording) {
|
||||||
|
mediaRecorder.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up without keeping the audio
|
||||||
|
state.audioBlob = null
|
||||||
|
state.audioUrl = null
|
||||||
|
state.duration = 0
|
||||||
|
|
||||||
|
if (stream) {
|
||||||
|
stream.getTracks().forEach(track => track.stop())
|
||||||
|
stream = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (durationInterval) {
|
||||||
|
clearInterval(durationInterval)
|
||||||
|
durationInterval = null
|
||||||
|
}
|
||||||
|
|
||||||
|
audioChunks = []
|
||||||
|
state.isRecording = false
|
||||||
|
state.isPaused = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause recording
|
||||||
|
const pauseRecording = (): void => {
|
||||||
|
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
||||||
|
mediaRecorder.pause()
|
||||||
|
state.isPaused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume recording
|
||||||
|
const resumeRecording = (): void => {
|
||||||
|
if (mediaRecorder && mediaRecorder.state === 'paused') {
|
||||||
|
mediaRecorder.resume()
|
||||||
|
state.isPaused = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert blob to File for upload
|
||||||
|
const getAudioFile = (filename?: string): File | null => {
|
||||||
|
if (!state.audioBlob) return null
|
||||||
|
|
||||||
|
const extension = state.audioBlob.type.includes('ogg') ? 'ogg' : 'webm'
|
||||||
|
const name = filename || `audio-${Date.now()}.${extension}`
|
||||||
|
|
||||||
|
return new File([state.audioBlob], name, { type: state.audioBlob.type })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear recorded audio
|
||||||
|
const clearAudio = (): void => {
|
||||||
|
if (state.audioUrl) {
|
||||||
|
URL.revokeObjectURL(state.audioUrl)
|
||||||
|
}
|
||||||
|
state.audioBlob = null
|
||||||
|
state.audioUrl = null
|
||||||
|
state.duration = 0
|
||||||
|
state.error = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
onUnmounted(() => {
|
||||||
|
cancelRecording()
|
||||||
|
clearAudio()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
isRecording: computed(() => state.isRecording),
|
||||||
|
isPaused: computed(() => state.isPaused),
|
||||||
|
duration: computed(() => state.duration),
|
||||||
|
audioBlob: computed(() => state.audioBlob),
|
||||||
|
audioUrl: computed(() => state.audioUrl),
|
||||||
|
error: computed(() => state.error),
|
||||||
|
isSupported,
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
startRecording,
|
||||||
|
stopRecording,
|
||||||
|
cancelRecording,
|
||||||
|
pauseRecording,
|
||||||
|
resumeRecording,
|
||||||
|
getAudioFile,
|
||||||
|
clearAudio
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -161,17 +161,26 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Messages -->
|
<!-- Messages -->
|
||||||
<div class="flex-1 overflow-y-auto p-4 space-y-2">
|
<div ref="messagesContainer" class="flex-1 overflow-y-auto p-4 space-y-2">
|
||||||
<MessagesMessageBubble
|
<MessagesMessageBubble
|
||||||
v-for="message in messages"
|
v-for="message in reversedMessages"
|
||||||
:key="message.id"
|
:key="message.id"
|
||||||
:message="message"
|
:message="message"
|
||||||
|
:instance-id="selectedInstance?.value || ''"
|
||||||
|
:is-group="selectedChat?.isGroup || false"
|
||||||
|
@reply="handleReply"
|
||||||
|
@react="handleReact"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input -->
|
<!-- Input -->
|
||||||
<div class="p-4 border-t border-[var(--wa-border)]">
|
<div class="p-4 border-t border-[var(--wa-border)]">
|
||||||
<MessagesMessageInput @send="handleSendMessage" />
|
<MessagesMessageInput
|
||||||
|
:replying-to="replyingTo"
|
||||||
|
@send="handleSendMessage"
|
||||||
|
@send-voice="handleSendVoice"
|
||||||
|
@cancel-reply="replyingTo = null"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -257,6 +266,27 @@ const filteredChats = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Reverse messages for display (API returns DESC, we want ASC for chronological order)
|
||||||
|
const reversedMessages = computed(() => {
|
||||||
|
return [...messages.value].reverse()
|
||||||
|
})
|
||||||
|
|
||||||
|
const messagesContainer = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
// Scroll to bottom when new messages arrive
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
if (messagesContainer.value) {
|
||||||
|
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for message changes and scroll to bottom
|
||||||
|
watch(messages, () => {
|
||||||
|
scrollToBottom()
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
// Copy to clipboard function
|
// Copy to clipboard function
|
||||||
const copyToClipboard = async (text: string) => {
|
const copyToClipboard = async (text: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -266,22 +296,97 @@ const copyToClipboard = async (text: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSendMessage = async (content: string) => {
|
// Reply state (will be used for quote functionality)
|
||||||
|
const replyingTo = ref<any>(null)
|
||||||
|
|
||||||
|
const handleSendMessage = async (content: string, files: File[], caption: string, quotedId?: string) => {
|
||||||
if (!selectedInstance.value?.value || !selectedChat.value) return
|
if (!selectedInstance.value?.value || !selectedChat.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await $fetch(`/api/messages/${selectedInstance.value.value}/${selectedChat.value.id}/send`, {
|
const instanceId = selectedInstance.value.value
|
||||||
method: 'POST',
|
const chatId = selectedChat.value.id
|
||||||
body: { content }
|
|
||||||
})
|
// If we have files, send as media
|
||||||
|
if (files.length > 0) {
|
||||||
|
const formData = new FormData()
|
||||||
|
|
||||||
|
// Add files
|
||||||
|
for (const file of files) {
|
||||||
|
formData.append('files', file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add caption if provided
|
||||||
|
if (caption) {
|
||||||
|
formData.append('caption', caption)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add quoted message ID if replying
|
||||||
|
if (quotedId || replyingTo.value?.messageId) {
|
||||||
|
formData.append('quotedMessageId', quotedId || replyingTo.value.messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
await $fetch(`/api/messages/${instanceId}/${chatId}/send-media`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
} else if (content) {
|
||||||
|
// Send text message
|
||||||
|
await $fetch(`/api/messages/${instanceId}/${chatId}/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
content,
|
||||||
|
quotedMessageId: quotedId || replyingTo.value?.messageId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear reply state
|
||||||
|
replyingTo.value = null
|
||||||
|
|
||||||
// Reload messages
|
// Reload messages
|
||||||
messages.value = await $fetch(`/api/messages/${selectedInstance.value.value}/${selectedChat.value.id}`)
|
messages.value = await $fetch(`/api/messages/${instanceId}/${chatId}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error sending message:', e)
|
console.error('Error sending message:', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle voice message
|
||||||
|
const handleSendVoice = async (audioFile: File) => {
|
||||||
|
if (!selectedInstance.value?.value || !selectedChat.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const instanceId = selectedInstance.value.value
|
||||||
|
const chatId = selectedChat.value.id
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('files', audioFile)
|
||||||
|
formData.append('isPtt', 'true') // Mark as push-to-talk (voice note)
|
||||||
|
|
||||||
|
await $fetch(`/api/messages/${instanceId}/${chatId}/send-media`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reload messages
|
||||||
|
messages.value = await $fetch(`/api/messages/${instanceId}/${chatId}`)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error sending voice message:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle reply action
|
||||||
|
const handleReply = (message: any) => {
|
||||||
|
replyingTo.value = message
|
||||||
|
// TODO: Focus input and show reply preview
|
||||||
|
console.log('Reply to:', message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle react action
|
||||||
|
const handleReact = (message: any) => {
|
||||||
|
// TODO: Show emoji picker
|
||||||
|
console.log('React to:', message)
|
||||||
|
}
|
||||||
|
|
||||||
// Reload chats for current instance
|
// Reload chats for current instance
|
||||||
const reloadChats = async () => {
|
const reloadChats = async () => {
|
||||||
if (!selectedInstance.value?.value) return
|
if (!selectedInstance.value?.value) return
|
||||||
|
|||||||
373
app/types/message.ts
Normal file
373
app/types/message.ts
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
/**
|
||||||
|
* Tipos centralizados para mensajes de WhatsApp
|
||||||
|
* Basado en la API de Baileys (@whiskeysockets/baileys)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Tipos de mensaje soportados por Baileys
|
||||||
|
export type MessageType =
|
||||||
|
| 'text'
|
||||||
|
| 'image'
|
||||||
|
| 'video'
|
||||||
|
| 'audio'
|
||||||
|
| 'document'
|
||||||
|
| 'sticker'
|
||||||
|
| 'contact'
|
||||||
|
| 'location'
|
||||||
|
| 'reaction'
|
||||||
|
| 'poll'
|
||||||
|
| 'unknown'
|
||||||
|
|
||||||
|
// Estados de mensaje
|
||||||
|
export type MessageStatus = 'pending' | 'sent' | 'delivered' | 'read' | 'failed'
|
||||||
|
|
||||||
|
// Estados de presencia
|
||||||
|
export type PresenceStatus = 'available' | 'unavailable' | 'composing' | 'recording' | 'paused'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Información de media (imagen, video, audio, documento, sticker)
|
||||||
|
*/
|
||||||
|
export interface MediaInfo {
|
||||||
|
/** URL del media (puede ser endpoint de API o URL directa) */
|
||||||
|
url?: string
|
||||||
|
/** Tipo MIME del archivo */
|
||||||
|
mimetype?: string
|
||||||
|
/** Nombre del archivo */
|
||||||
|
filename?: string
|
||||||
|
/** Tamaño en bytes */
|
||||||
|
filesize?: number
|
||||||
|
/** Ancho en píxeles (para imagen/video/sticker) */
|
||||||
|
width?: number
|
||||||
|
/** Alto en píxeles (para imagen/video/sticker) */
|
||||||
|
height?: number
|
||||||
|
/** Duración en segundos (para audio/video) */
|
||||||
|
duration?: number
|
||||||
|
/** Thumbnail en base64 */
|
||||||
|
thumbnail?: string
|
||||||
|
/** Si es mensaje de vista única */
|
||||||
|
isViewOnce?: boolean
|
||||||
|
/** Si es audio de voz (PTT) */
|
||||||
|
isPtt?: boolean
|
||||||
|
/** Waveform del audio (array de bytes) */
|
||||||
|
waveform?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Información de ubicación
|
||||||
|
*/
|
||||||
|
export interface LocationInfo {
|
||||||
|
/** Latitud */
|
||||||
|
latitude: number
|
||||||
|
/** Longitud */
|
||||||
|
longitude: number
|
||||||
|
/** Nombre del lugar */
|
||||||
|
name?: string
|
||||||
|
/** Dirección */
|
||||||
|
address?: string
|
||||||
|
/** URL del mapa */
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Información de contacto compartido
|
||||||
|
*/
|
||||||
|
export interface ContactInfo {
|
||||||
|
/** Nombre para mostrar */
|
||||||
|
displayName: string
|
||||||
|
/** vCard completo */
|
||||||
|
vcard: string
|
||||||
|
/** Números de teléfono extraídos */
|
||||||
|
phones?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Información de mensaje citado (quoted/reply)
|
||||||
|
*/
|
||||||
|
export interface QuotedMessage {
|
||||||
|
/** ID del mensaje original */
|
||||||
|
id: string
|
||||||
|
/** Contenido del mensaje original */
|
||||||
|
content: string | null
|
||||||
|
/** Tipo del mensaje original */
|
||||||
|
type: MessageType
|
||||||
|
/** Si fue enviado por mí */
|
||||||
|
fromMe: boolean
|
||||||
|
/** JID del autor original (en grupos) */
|
||||||
|
participant?: string
|
||||||
|
/** Nombre del autor original */
|
||||||
|
participantName?: string
|
||||||
|
/** Media info si era un mensaje con media */
|
||||||
|
media?: MediaInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Información de reacción a un mensaje
|
||||||
|
*/
|
||||||
|
export interface ReactionInfo {
|
||||||
|
/** Emoji de la reacción */
|
||||||
|
emoji: string
|
||||||
|
/** JID de quien reaccionó */
|
||||||
|
reactorJid: string
|
||||||
|
/** Nombre de quien reaccionó */
|
||||||
|
reactorName?: string
|
||||||
|
/** Timestamp de la reacción */
|
||||||
|
timestamp?: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mensaje completo con toda la información
|
||||||
|
*/
|
||||||
|
export interface Message {
|
||||||
|
/** UUID interno de la BD */
|
||||||
|
id: string
|
||||||
|
/** ID del mensaje de WhatsApp */
|
||||||
|
messageId: string
|
||||||
|
/** ID del chat */
|
||||||
|
chatId?: string
|
||||||
|
/** JID del remitente */
|
||||||
|
fromJid: string
|
||||||
|
/** Si fue enviado por mí */
|
||||||
|
fromMe: boolean
|
||||||
|
/** Tipo de mensaje */
|
||||||
|
type: MessageType
|
||||||
|
/** Contenido de texto (para text/caption) */
|
||||||
|
content: string | null
|
||||||
|
/** Caption para media */
|
||||||
|
caption?: string
|
||||||
|
/** Información de media */
|
||||||
|
media?: MediaInfo
|
||||||
|
/** Información de ubicación */
|
||||||
|
location?: LocationInfo
|
||||||
|
/** Información de contacto */
|
||||||
|
contact?: ContactInfo
|
||||||
|
/** Mensaje citado */
|
||||||
|
quoted?: QuotedMessage
|
||||||
|
/** Reacciones al mensaje */
|
||||||
|
reactions?: ReactionInfo[]
|
||||||
|
/** Timestamp del mensaje */
|
||||||
|
timestamp: Date
|
||||||
|
/** Estado del mensaje */
|
||||||
|
status: MessageStatus
|
||||||
|
/** JID del participante en grupos */
|
||||||
|
participant?: string
|
||||||
|
/** Nombre del remitente (pushName) */
|
||||||
|
pushName?: string
|
||||||
|
/** Si es un mensaje del sistema (stub) */
|
||||||
|
isStub?: boolean
|
||||||
|
/** Tipo de stub (si aplica) */
|
||||||
|
stubType?: string
|
||||||
|
/** Parámetros del stub */
|
||||||
|
stubParams?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Información de presencia de un usuario
|
||||||
|
*/
|
||||||
|
export interface PresenceInfo {
|
||||||
|
/** Estado actual */
|
||||||
|
status: PresenceStatus
|
||||||
|
/** Última vez visto */
|
||||||
|
lastSeen?: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Participante de grupo
|
||||||
|
*/
|
||||||
|
export interface GroupParticipant {
|
||||||
|
/** JID del participante */
|
||||||
|
jid: string
|
||||||
|
/** Nombre del participante */
|
||||||
|
name?: string
|
||||||
|
/** Si es administrador */
|
||||||
|
isAdmin: boolean
|
||||||
|
/** Si es super administrador (creador) */
|
||||||
|
isSuperAdmin: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chat/Conversación
|
||||||
|
*/
|
||||||
|
export interface Chat {
|
||||||
|
/** UUID interno */
|
||||||
|
id: string
|
||||||
|
/** JID del chat */
|
||||||
|
jid: string
|
||||||
|
/** Nombre del chat */
|
||||||
|
name: string
|
||||||
|
/** Si es grupo */
|
||||||
|
isGroup: boolean
|
||||||
|
/** URL de foto de perfil */
|
||||||
|
profilePicture?: string
|
||||||
|
/** Último mensaje (texto o placeholder) */
|
||||||
|
lastMessage: string
|
||||||
|
/** Tipo del último mensaje */
|
||||||
|
lastMessageType?: MessageType
|
||||||
|
/** Timestamp del último mensaje */
|
||||||
|
lastMessageAt: Date
|
||||||
|
/** Contador de mensajes no leídos */
|
||||||
|
unreadCount: number
|
||||||
|
/** Presencia actual (si está suscrito) */
|
||||||
|
presence?: PresenceInfo
|
||||||
|
/** Participantes (solo para grupos) */
|
||||||
|
participants?: GroupParticipant[]
|
||||||
|
/** Si está archivado */
|
||||||
|
isArchived?: boolean
|
||||||
|
/** Si está fijado */
|
||||||
|
isPinned?: boolean
|
||||||
|
/** Si está silenciado */
|
||||||
|
isMuted?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contenido para enviar un mensaje
|
||||||
|
*/
|
||||||
|
export interface SendMessageContent {
|
||||||
|
/** Texto del mensaje */
|
||||||
|
text?: string
|
||||||
|
/** Archivo de imagen */
|
||||||
|
image?: File | string
|
||||||
|
/** Archivo de video */
|
||||||
|
video?: File | string
|
||||||
|
/** Archivo de audio */
|
||||||
|
audio?: File | string
|
||||||
|
/** Archivo de documento */
|
||||||
|
document?: File | string
|
||||||
|
/** Caption para media */
|
||||||
|
caption?: string
|
||||||
|
/** ID del mensaje a citar */
|
||||||
|
quotedMessageId?: string
|
||||||
|
/** JIDs para mencionar */
|
||||||
|
mentions?: string[]
|
||||||
|
/** Si es audio PTT (nota de voz) */
|
||||||
|
isPtt?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evento de actualización de presencia
|
||||||
|
*/
|
||||||
|
export interface PresenceUpdateEvent {
|
||||||
|
/** ID de la instancia */
|
||||||
|
instanceId: string
|
||||||
|
/** JID del chat */
|
||||||
|
jid: string
|
||||||
|
/** Presencias por participante */
|
||||||
|
presences: Record<string, PresenceInfo>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evento de actualización de estado de mensaje
|
||||||
|
*/
|
||||||
|
export interface MessageStatusEvent {
|
||||||
|
/** ID de la instancia */
|
||||||
|
instanceId: string
|
||||||
|
/** ID del mensaje */
|
||||||
|
messageId: string
|
||||||
|
/** Nuevo estado */
|
||||||
|
status: MessageStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evento de nuevo mensaje
|
||||||
|
*/
|
||||||
|
export interface MessageReceivedEvent {
|
||||||
|
/** ID de la instancia */
|
||||||
|
instanceId: string
|
||||||
|
/** Mensaje completo */
|
||||||
|
message: Message
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evento de reacción a mensaje
|
||||||
|
*/
|
||||||
|
export interface MessageReactionEvent {
|
||||||
|
/** ID de la instancia */
|
||||||
|
instanceId: string
|
||||||
|
/** ID del mensaje */
|
||||||
|
messageId: string
|
||||||
|
/** Información de la reacción */
|
||||||
|
reaction: ReactionInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utilidades
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el placeholder de texto para un tipo de mensaje
|
||||||
|
*/
|
||||||
|
export function getMessageTypePlaceholder(type: MessageType): string {
|
||||||
|
const placeholders: Record<MessageType, string> = {
|
||||||
|
text: '',
|
||||||
|
image: 'Foto',
|
||||||
|
video: 'Video',
|
||||||
|
audio: 'Audio',
|
||||||
|
document: 'Documento',
|
||||||
|
sticker: 'Sticker',
|
||||||
|
contact: 'Contacto',
|
||||||
|
location: 'Ubicación',
|
||||||
|
reaction: 'Reacción',
|
||||||
|
poll: 'Encuesta',
|
||||||
|
unknown: 'Mensaje'
|
||||||
|
}
|
||||||
|
return placeholders[type] || 'Mensaje'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el ícono para un tipo de mensaje
|
||||||
|
*/
|
||||||
|
export function getMessageTypeIcon(type: MessageType): string {
|
||||||
|
const icons: Record<MessageType, string> = {
|
||||||
|
text: 'i-lucide-message-square',
|
||||||
|
image: 'i-lucide-image',
|
||||||
|
video: 'i-lucide-video',
|
||||||
|
audio: 'i-lucide-music',
|
||||||
|
document: 'i-lucide-file',
|
||||||
|
sticker: 'i-lucide-smile',
|
||||||
|
contact: 'i-lucide-user',
|
||||||
|
location: 'i-lucide-map-pin',
|
||||||
|
reaction: 'i-lucide-heart',
|
||||||
|
poll: 'i-lucide-bar-chart',
|
||||||
|
unknown: 'i-lucide-help-circle'
|
||||||
|
}
|
||||||
|
return icons[type] || 'i-lucide-message-square'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera un color consistente basado en un string (para nombres en grupos)
|
||||||
|
*/
|
||||||
|
export function stringToColor(str: string): string {
|
||||||
|
const colors = [
|
||||||
|
'#e17076', // rojo
|
||||||
|
'#faa774', // naranja
|
||||||
|
'#a695e7', // morado
|
||||||
|
'#7bc862', // verde
|
||||||
|
'#6ec9cb', // cyan
|
||||||
|
'#65aadd', // azul
|
||||||
|
'#ee7aae', // rosa
|
||||||
|
'#e5a36b', // marrón
|
||||||
|
]
|
||||||
|
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hash = str.charCodeAt(i) + ((hash << 5) - hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
return colors[Math.abs(hash) % colors.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatea bytes a tamaño legible
|
||||||
|
*/
|
||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatea duración en segundos a mm:ss
|
||||||
|
*/
|
||||||
|
export function formatDuration(seconds: number): string {
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = Math.floor(seconds % 60)
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
78
server/api/media/[instanceId]/[messageId].get.ts
Normal file
78
server/api/media/[instanceId]/[messageId].get.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/media/:instanceId/:messageId
|
||||||
|
* Download and serve media for a message
|
||||||
|
*
|
||||||
|
* This endpoint:
|
||||||
|
* 1. Checks if media is already cached locally
|
||||||
|
* 2. If not, downloads from WhatsApp using Baileys
|
||||||
|
* 3. Caches the file locally
|
||||||
|
* 4. Streams the file to the client
|
||||||
|
*/
|
||||||
|
import { createReadStream } from 'fs'
|
||||||
|
import { stat } from 'fs/promises'
|
||||||
|
import { downloadAndCacheMedia, getCachedMediaPath } from '../../../services/media/downloader'
|
||||||
|
import { sendStream } from 'h3'
|
||||||
|
|
||||||
|
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 messageId = getRouterParam(event, 'messageId')
|
||||||
|
|
||||||
|
if (!instanceId || !messageId) {
|
||||||
|
throw createError({ statusCode: 400, message: 'Missing instanceId or messageId' })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First check if already cached
|
||||||
|
let cached = await getCachedMediaPath(instanceId, messageId)
|
||||||
|
|
||||||
|
// If not cached, download and cache
|
||||||
|
if (!cached) {
|
||||||
|
console.log(`[Media API] Downloading media for: ${messageId}`)
|
||||||
|
const result = await downloadAndCacheMedia(instanceId, messageId)
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: 'Media not found or could not be downloaded'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
cached = { path: result.path, mimetype: result.mimetype }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file stats for Content-Length
|
||||||
|
const stats = await stat(cached.path)
|
||||||
|
|
||||||
|
// Set response headers
|
||||||
|
setHeader(event, 'Content-Type', cached.mimetype)
|
||||||
|
setHeader(event, 'Content-Length', stats.size.toString())
|
||||||
|
setHeader(event, 'Cache-Control', 'public, max-age=86400') // Cache for 24 hours
|
||||||
|
|
||||||
|
// For documents, suggest download with filename
|
||||||
|
const query = getQuery(event)
|
||||||
|
if (query.download === 'true' || cached.mimetype.startsWith('application/')) {
|
||||||
|
const filename = (query.filename as string) || `media-${messageId}`
|
||||||
|
setHeader(event, 'Content-Disposition', `attachment; filename="${filename}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream the file
|
||||||
|
const stream = createReadStream(cached.path)
|
||||||
|
return sendStream(event, stream)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[Media API] Error serving media ${messageId}:`, error)
|
||||||
|
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
message: 'Error retrieving media'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* GET /api/messages/:instanceId/:chatId
|
* GET /api/messages/:instanceId/:chatId
|
||||||
* Get messages for a chat
|
* Get messages for a chat with full parsed data from raw_message
|
||||||
*/
|
*/
|
||||||
import { query } from '../../../../utils/database'
|
import { query } from '../../../../utils/database'
|
||||||
|
|
||||||
@@ -15,6 +15,201 @@ interface MessageRow {
|
|||||||
media_url: string | null
|
media_url: string | null
|
||||||
timestamp: Date
|
timestamp: Date
|
||||||
status: string
|
status: string
|
||||||
|
raw_message: any
|
||||||
|
participant_jid: string | null
|
||||||
|
push_name: string | null
|
||||||
|
quoted_message_id: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatRow {
|
||||||
|
is_group: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse different message types from raw Baileys message
|
||||||
|
function parseRawMessage(raw: any, messageType: string) {
|
||||||
|
if (!raw?.message) return {}
|
||||||
|
|
||||||
|
const msg = raw.message
|
||||||
|
const result: any = {}
|
||||||
|
|
||||||
|
// Extract media info based on type
|
||||||
|
if (messageType === 'image' && msg.imageMessage) {
|
||||||
|
result.media = {
|
||||||
|
mimetype: msg.imageMessage.mimetype,
|
||||||
|
filesize: msg.imageMessage.fileLength ? Number(msg.imageMessage.fileLength) : undefined,
|
||||||
|
width: msg.imageMessage.width,
|
||||||
|
height: msg.imageMessage.height,
|
||||||
|
thumbnail: msg.imageMessage.jpegThumbnail
|
||||||
|
? Buffer.from(msg.imageMessage.jpegThumbnail).toString('base64')
|
||||||
|
: undefined,
|
||||||
|
isViewOnce: !!msg.imageMessage.viewOnce
|
||||||
|
}
|
||||||
|
result.caption = msg.imageMessage.caption
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageType === 'video' && msg.videoMessage) {
|
||||||
|
result.media = {
|
||||||
|
mimetype: msg.videoMessage.mimetype,
|
||||||
|
filesize: msg.videoMessage.fileLength ? Number(msg.videoMessage.fileLength) : undefined,
|
||||||
|
width: msg.videoMessage.width,
|
||||||
|
height: msg.videoMessage.height,
|
||||||
|
duration: msg.videoMessage.seconds,
|
||||||
|
thumbnail: msg.videoMessage.jpegThumbnail
|
||||||
|
? Buffer.from(msg.videoMessage.jpegThumbnail).toString('base64')
|
||||||
|
: undefined,
|
||||||
|
isViewOnce: !!msg.videoMessage.viewOnce
|
||||||
|
}
|
||||||
|
result.caption = msg.videoMessage.caption
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageType === 'audio' && msg.audioMessage) {
|
||||||
|
result.media = {
|
||||||
|
mimetype: msg.audioMessage.mimetype,
|
||||||
|
filesize: msg.audioMessage.fileLength ? Number(msg.audioMessage.fileLength) : undefined,
|
||||||
|
duration: msg.audioMessage.seconds,
|
||||||
|
isPtt: !!msg.audioMessage.ptt,
|
||||||
|
waveform: msg.audioMessage.waveform
|
||||||
|
? Array.from(msg.audioMessage.waveform)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageType === 'document' && msg.documentMessage) {
|
||||||
|
result.media = {
|
||||||
|
mimetype: msg.documentMessage.mimetype,
|
||||||
|
filename: msg.documentMessage.fileName,
|
||||||
|
filesize: msg.documentMessage.fileLength ? Number(msg.documentMessage.fileLength) : undefined,
|
||||||
|
thumbnail: msg.documentMessage.jpegThumbnail
|
||||||
|
? Buffer.from(msg.documentMessage.jpegThumbnail).toString('base64')
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
result.caption = msg.documentMessage.caption
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageType === 'sticker' && msg.stickerMessage) {
|
||||||
|
result.media = {
|
||||||
|
mimetype: msg.stickerMessage.mimetype,
|
||||||
|
width: msg.stickerMessage.width,
|
||||||
|
height: msg.stickerMessage.height,
|
||||||
|
filesize: msg.stickerMessage.fileLength ? Number(msg.stickerMessage.fileLength) : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageType === 'contact' && msg.contactMessage) {
|
||||||
|
const vcard = msg.contactMessage.vcard || ''
|
||||||
|
const phones = extractPhonesFromVCard(vcard)
|
||||||
|
|
||||||
|
result.contact = {
|
||||||
|
displayName: msg.contactMessage.displayName,
|
||||||
|
vcard: vcard,
|
||||||
|
phones: phones
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageType === 'location' && msg.locationMessage) {
|
||||||
|
result.location = {
|
||||||
|
latitude: msg.locationMessage.degreesLatitude,
|
||||||
|
longitude: msg.locationMessage.degreesLongitude,
|
||||||
|
name: msg.locationMessage.name,
|
||||||
|
address: msg.locationMessage.address,
|
||||||
|
url: msg.locationMessage.url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract quoted message if exists
|
||||||
|
const contextInfo = getContextInfo(msg)
|
||||||
|
if (contextInfo?.quotedMessage) {
|
||||||
|
const quotedType = getMessageType(contextInfo.quotedMessage)
|
||||||
|
result.quoted = {
|
||||||
|
id: contextInfo.stanzaId,
|
||||||
|
content: extractTextContent(contextInfo.quotedMessage),
|
||||||
|
type: quotedType,
|
||||||
|
fromMe: contextInfo.participant === raw.key?.participant || false,
|
||||||
|
participant: contextInfo.participant,
|
||||||
|
participantName: null // Will be filled from contacts if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add media thumbnail for quoted media messages
|
||||||
|
if (['image', 'video', 'sticker'].includes(quotedType)) {
|
||||||
|
const quotedMedia = contextInfo.quotedMessage[`${quotedType}Message`]
|
||||||
|
if (quotedMedia?.jpegThumbnail) {
|
||||||
|
result.quoted.media = {
|
||||||
|
thumbnail: Buffer.from(quotedMedia.jpegThumbnail).toString('base64')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract participant for group messages
|
||||||
|
if (raw.key?.participant) {
|
||||||
|
result.participant = raw.key.participant
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract pushName
|
||||||
|
if (raw.pushName) {
|
||||||
|
result.pushName = raw.pushName
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get context info from any message type
|
||||||
|
function getContextInfo(msg: any): any {
|
||||||
|
const messageTypes = [
|
||||||
|
'extendedTextMessage',
|
||||||
|
'imageMessage',
|
||||||
|
'videoMessage',
|
||||||
|
'audioMessage',
|
||||||
|
'documentMessage',
|
||||||
|
'stickerMessage',
|
||||||
|
'contactMessage',
|
||||||
|
'locationMessage'
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const type of messageTypes) {
|
||||||
|
if (msg[type]?.contextInfo) {
|
||||||
|
return msg[type].contextInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get message type from message content
|
||||||
|
function getMessageType(msg: any): string {
|
||||||
|
if (msg.conversation || msg.extendedTextMessage) return 'text'
|
||||||
|
if (msg.imageMessage) return 'image'
|
||||||
|
if (msg.videoMessage) return 'video'
|
||||||
|
if (msg.audioMessage) return 'audio'
|
||||||
|
if (msg.documentMessage) return 'document'
|
||||||
|
if (msg.stickerMessage) return 'sticker'
|
||||||
|
if (msg.contactMessage) return 'contact'
|
||||||
|
if (msg.locationMessage) return 'location'
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract text content from any message type
|
||||||
|
function extractTextContent(msg: any): string | null {
|
||||||
|
if (msg.conversation) return msg.conversation
|
||||||
|
if (msg.extendedTextMessage?.text) return msg.extendedTextMessage.text
|
||||||
|
if (msg.imageMessage?.caption) return msg.imageMessage.caption
|
||||||
|
if (msg.videoMessage?.caption) return msg.videoMessage.caption
|
||||||
|
if (msg.documentMessage?.caption) return msg.documentMessage.caption
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract phone numbers from vCard
|
||||||
|
function extractPhonesFromVCard(vcard: string): string[] {
|
||||||
|
const phones: string[] = []
|
||||||
|
const telRegex = /TEL[^:]*:([^\n\r]+)/gi
|
||||||
|
let match
|
||||||
|
|
||||||
|
while ((match = telRegex.exec(vcard)) !== null) {
|
||||||
|
const phone = match[1].trim().replace(/[^\d+]/g, '')
|
||||||
|
if (phone) phones.push(phone)
|
||||||
|
}
|
||||||
|
|
||||||
|
return phones
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
@@ -30,26 +225,38 @@ export default defineEventHandler(async (event) => {
|
|||||||
const queryParams = getQuery(event)
|
const queryParams = getQuery(event)
|
||||||
const limit = Math.min(parseInt(queryParams.limit as string) || 50, 100)
|
const limit = Math.min(parseInt(queryParams.limit as string) || 50, 100)
|
||||||
const offset = parseInt(queryParams.offset as string) || 0
|
const offset = parseInt(queryParams.offset as string) || 0
|
||||||
|
const before = queryParams.before as string // For infinite scroll
|
||||||
|
|
||||||
// Verify chat exists and belongs to instance
|
// Verify chat exists and get info
|
||||||
const chatCheck = await query(
|
const chatCheck = await query<ChatRow>(
|
||||||
'SELECT id FROM chats WHERE id = $1 AND instance_id = $2',
|
'SELECT id, is_group FROM chats WHERE id = $1 AND instance_id = $2',
|
||||||
[chatId, instanceId]
|
[chatId, instanceId]
|
||||||
)
|
)
|
||||||
if (chatCheck.rows.length === 0) {
|
if (chatCheck.rows.length === 0) {
|
||||||
throw createError({ statusCode: 404, message: 'Chat not found' })
|
throw createError({ statusCode: 404, message: 'Chat not found' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get messages
|
const isGroup = chatCheck.rows[0].is_group
|
||||||
const result = await query<MessageRow>(
|
|
||||||
`SELECT id, message_id, from_jid, from_me, message_type,
|
// Build query with optional before parameter for infinite scroll
|
||||||
content, caption, media_url, timestamp, status
|
let messagesQuery = `
|
||||||
FROM messages
|
SELECT id, message_id, from_jid, from_me, message_type,
|
||||||
WHERE chat_id = $1
|
content, caption, media_url, timestamp, status,
|
||||||
ORDER BY timestamp DESC
|
raw_message, participant_jid, push_name, quoted_message_id
|
||||||
LIMIT $2 OFFSET $3`,
|
FROM messages
|
||||||
[chatId, limit, offset]
|
WHERE chat_id = $1
|
||||||
)
|
`
|
||||||
|
const params: any[] = [chatId]
|
||||||
|
|
||||||
|
if (before) {
|
||||||
|
messagesQuery += ` AND timestamp < $${params.length + 1}`
|
||||||
|
params.push(before)
|
||||||
|
}
|
||||||
|
|
||||||
|
messagesQuery += ` ORDER BY timestamp DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`
|
||||||
|
params.push(limit, offset)
|
||||||
|
|
||||||
|
const result = await query<MessageRow>(messagesQuery, params)
|
||||||
|
|
||||||
// Mark as read
|
// Mark as read
|
||||||
await query(
|
await query(
|
||||||
@@ -57,16 +264,28 @@ export default defineEventHandler(async (event) => {
|
|||||||
[chatId]
|
[chatId]
|
||||||
)
|
)
|
||||||
|
|
||||||
return result.rows.map(row => ({
|
// Parse and return messages
|
||||||
id: row.id,
|
return result.rows.map(row => {
|
||||||
messageId: row.message_id,
|
const parsedData = parseRawMessage(row.raw_message, row.message_type)
|
||||||
fromJid: row.from_jid,
|
|
||||||
fromMe: row.from_me,
|
return {
|
||||||
type: row.message_type,
|
id: row.id,
|
||||||
content: row.content,
|
messageId: row.message_id,
|
||||||
caption: row.caption,
|
chatId: chatId,
|
||||||
mediaUrl: row.media_url,
|
fromJid: row.from_jid,
|
||||||
timestamp: row.timestamp,
|
fromMe: row.from_me,
|
||||||
status: row.status
|
type: row.message_type || 'unknown',
|
||||||
}))
|
content: row.content,
|
||||||
|
caption: row.caption || parsedData.caption,
|
||||||
|
media: parsedData.media || (row.media_url ? { url: row.media_url } : undefined),
|
||||||
|
location: parsedData.location,
|
||||||
|
contact: parsedData.contact,
|
||||||
|
quoted: parsedData.quoted,
|
||||||
|
timestamp: row.timestamp,
|
||||||
|
status: row.status || 'sent',
|
||||||
|
participant: row.participant_jid || parsedData.participant,
|
||||||
|
pushName: row.push_name || parsedData.pushName,
|
||||||
|
isGroup: isGroup
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
234
server/api/messages/[instanceId]/[chatId]/send-media.post.ts
Normal file
234
server/api/messages/[instanceId]/[chatId]/send-media.post.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add quoted message if exists
|
||||||
|
if (quotedMessage) {
|
||||||
|
(content as any).quoted = quotedMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send message
|
||||||
|
console.log(`[SendMedia] Sending ${mediaType} to ${jid}`)
|
||||||
|
const result = await socket.sendMessage(jid, content)
|
||||||
|
|
||||||
|
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]
|
||||||
|
)
|
||||||
|
}
|
||||||
85
server/database/migrations/002_messages_enhanced.sql
Normal file
85
server/database/migrations/002_messages_enhanced.sql
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Migration 002: Enhanced Messages Support
|
||||||
|
-- =====================================================
|
||||||
|
-- Adds fields for:
|
||||||
|
-- - Group participant tracking (participant_jid, push_name)
|
||||||
|
-- - Media caching (media_cached, media_local_path, media_size_bytes)
|
||||||
|
-- - Message reactions
|
||||||
|
-- - Presence caching
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Add participant tracking fields to messages
|
||||||
|
ALTER TABLE messages ADD COLUMN IF NOT EXISTS participant_jid VARCHAR(100);
|
||||||
|
ALTER TABLE messages ADD COLUMN IF NOT EXISTS push_name VARCHAR(255);
|
||||||
|
|
||||||
|
-- Add media caching fields
|
||||||
|
ALTER TABLE messages ADD COLUMN IF NOT EXISTS media_cached BOOLEAN DEFAULT FALSE;
|
||||||
|
ALTER TABLE messages ADD COLUMN IF NOT EXISTS media_local_path TEXT;
|
||||||
|
ALTER TABLE messages ADD COLUMN IF NOT EXISTS media_size_bytes BIGINT;
|
||||||
|
|
||||||
|
-- Create table for message reactions
|
||||||
|
CREATE TABLE IF NOT EXISTS message_reactions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||||
|
instance_id UUID NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
|
||||||
|
reactor_jid VARCHAR(100) NOT NULL,
|
||||||
|
reactor_name VARCHAR(255),
|
||||||
|
emoji VARCHAR(20) NOT NULL,
|
||||||
|
timestamp TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT unique_reaction UNIQUE (message_id, reactor_jid)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reactions_message ON message_reactions(message_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reactions_instance ON message_reactions(instance_id);
|
||||||
|
|
||||||
|
-- Create table for presence caching (optional, for "last seen" persistence)
|
||||||
|
CREATE TABLE IF NOT EXISTS presence_cache (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
instance_id UUID NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
|
||||||
|
jid VARCHAR(100) NOT NULL,
|
||||||
|
presence VARCHAR(20) CHECK (presence IN ('available', 'unavailable', 'composing', 'recording', 'paused')),
|
||||||
|
last_seen TIMESTAMPTZ,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT unique_presence UNIQUE (instance_id, jid)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_presence_instance ON presence_cache(instance_id);
|
||||||
|
|
||||||
|
-- Create table for group metadata caching
|
||||||
|
CREATE TABLE IF NOT EXISTS group_metadata (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
instance_id UUID NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
|
||||||
|
jid VARCHAR(100) NOT NULL,
|
||||||
|
subject VARCHAR(255),
|
||||||
|
description TEXT,
|
||||||
|
owner_jid VARCHAR(100),
|
||||||
|
participants JSONB DEFAULT '[]',
|
||||||
|
announce_only BOOLEAN DEFAULT FALSE,
|
||||||
|
restrict_edit BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT unique_group UNIQUE (instance_id, jid)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_group_metadata_instance ON group_metadata(instance_id);
|
||||||
|
|
||||||
|
-- Add trigger for presence_cache updated_at
|
||||||
|
CREATE TRIGGER update_presence_cache_updated_at
|
||||||
|
BEFORE UPDATE ON presence_cache
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- Add trigger for group_metadata updated_at
|
||||||
|
CREATE TRIGGER update_group_metadata_updated_at
|
||||||
|
BEFORE UPDATE ON group_metadata
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- Add index for media caching lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_media_cached ON messages(media_cached) WHERE media_cached = TRUE;
|
||||||
|
|
||||||
|
-- Add last_message_type to chats for preview icons
|
||||||
|
ALTER TABLE chats ADD COLUMN IF NOT EXISTS last_message_type VARCHAR(50);
|
||||||
@@ -47,6 +47,8 @@ export interface InstanceEvents {
|
|||||||
'message.received': { instanceId: string; message: any }
|
'message.received': { instanceId: string; message: any }
|
||||||
'message.sent': { instanceId: string; message: any }
|
'message.sent': { instanceId: string; message: any }
|
||||||
'message.status': { instanceId: string; messageId: string; status: string }
|
'message.status': { instanceId: string; messageId: string; status: string }
|
||||||
|
'message.reaction': { instanceId: string; reaction: any }
|
||||||
|
'presence.update': { instanceId: string; jid: string; presences: Record<string, { lastKnownPresence: string; lastSeen?: number }> }
|
||||||
}
|
}
|
||||||
|
|
||||||
const logger = pino({ level: 'warn' })
|
const logger = pino({ level: 'warn' })
|
||||||
@@ -372,6 +374,74 @@ class BaileysManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Presence update
|
||||||
|
socket.ev.on('presence.update', async (update) => {
|
||||||
|
console.log(`[BaileysManager] presence.update:`, JSON.stringify(update))
|
||||||
|
this.emit('presence.update', {
|
||||||
|
instanceId,
|
||||||
|
jid: update.id,
|
||||||
|
presences: update.presences
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cache presence in database
|
||||||
|
for (const [participantJid, presence] of Object.entries(update.presences)) {
|
||||||
|
try {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO presence_cache (instance_id, jid, presence, last_seen)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (instance_id, jid) DO UPDATE SET
|
||||||
|
presence = EXCLUDED.presence,
|
||||||
|
last_seen = COALESCE(EXCLUDED.last_seen, presence_cache.last_seen)`,
|
||||||
|
[
|
||||||
|
instanceId,
|
||||||
|
participantJid,
|
||||||
|
presence.lastKnownPresence,
|
||||||
|
presence.lastSeen ? new Date(presence.lastSeen * 1000) : null
|
||||||
|
]
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[BaileysManager] Error caching presence:`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Message reactions
|
||||||
|
socket.ev.on('messages.reaction', async (reactions) => {
|
||||||
|
for (const reaction of reactions) {
|
||||||
|
console.log(`[BaileysManager] message.reaction:`, JSON.stringify(reaction))
|
||||||
|
this.emit('message.reaction', { instanceId, reaction })
|
||||||
|
|
||||||
|
// Save reaction to database
|
||||||
|
try {
|
||||||
|
const { key, reaction: reactionData } = reaction
|
||||||
|
if (reactionData.text) {
|
||||||
|
// Add reaction
|
||||||
|
await query(
|
||||||
|
`INSERT INTO message_reactions (message_id, reactor_jid, emoji)
|
||||||
|
SELECT m.id, $2, $3
|
||||||
|
FROM messages m
|
||||||
|
WHERE m.instance_id = $1 AND m.message_id = $4
|
||||||
|
ON CONFLICT (message_id, reactor_jid) DO UPDATE SET
|
||||||
|
emoji = EXCLUDED.emoji,
|
||||||
|
timestamp = NOW()`,
|
||||||
|
[instanceId, key.participant || key.fromMe ? 'me' : key.remoteJid, reactionData.text, reactionData.key.id]
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Remove reaction (empty text)
|
||||||
|
await query(
|
||||||
|
`DELETE FROM message_reactions
|
||||||
|
WHERE message_id IN (
|
||||||
|
SELECT id FROM messages WHERE instance_id = $1 AND message_id = $2
|
||||||
|
) AND reactor_jid = $3`,
|
||||||
|
[instanceId, reactionData.key.id, key.participant || key.fromMe ? 'me' : key.remoteJid]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[BaileysManager] Error saving reaction:`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// History sync - save chats and messages from history
|
// History sync - save chats and messages from history
|
||||||
socket.ev.on('messaging-history.set', async ({ chats, contacts, messages, syncType }) => {
|
socket.ev.on('messaging-history.set', async ({ chats, contacts, messages, syncType }) => {
|
||||||
console.log(`[BaileysManager] History sync received: ${chats?.length || 0} chats, ${contacts?.length || 0} contacts, ${messages?.length || 0} messages, type: ${syncType}`)
|
console.log(`[BaileysManager] History sync received: ${chats?.length || 0} chats, ${contacts?.length || 0} contacts, ${messages?.length || 0} messages, type: ${syncType}`)
|
||||||
@@ -477,6 +547,53 @@ class BaileysManager extends EventEmitter {
|
|||||||
return managed.socket
|
return managed.socket
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to presence updates for a JID
|
||||||
|
*/
|
||||||
|
async subscribeToPresence(instanceId: string, jid: string): Promise<void> {
|
||||||
|
const managed = this.instances.get(instanceId)
|
||||||
|
if (!managed?.socket) {
|
||||||
|
throw new Error('Instance not connected')
|
||||||
|
}
|
||||||
|
|
||||||
|
await managed.socket.presenceSubscribe(jid)
|
||||||
|
console.log(`[BaileysManager] Subscribed to presence: ${jid}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send presence update (composing, recording, available, unavailable, paused)
|
||||||
|
*/
|
||||||
|
async sendPresence(instanceId: string, jid: string, presence: 'composing' | 'recording' | 'available' | 'unavailable' | 'paused'): Promise<void> {
|
||||||
|
const managed = this.instances.get(instanceId)
|
||||||
|
if (!managed?.socket) {
|
||||||
|
throw new Error('Instance not connected')
|
||||||
|
}
|
||||||
|
|
||||||
|
await managed.socket.sendPresenceUpdate(presence, jid)
|
||||||
|
console.log(`[BaileysManager] Sent presence ${presence} to ${jid}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a reaction to a message
|
||||||
|
*/
|
||||||
|
async sendReaction(instanceId: string, jid: string, messageId: string, emoji: string): Promise<void> {
|
||||||
|
const managed = this.instances.get(instanceId)
|
||||||
|
if (!managed?.socket) {
|
||||||
|
throw new Error('Instance not connected')
|
||||||
|
}
|
||||||
|
|
||||||
|
await managed.socket.sendMessage(jid, {
|
||||||
|
react: {
|
||||||
|
text: emoji,
|
||||||
|
key: {
|
||||||
|
remoteJid: jid,
|
||||||
|
id: messageId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log(`[BaileysManager] Sent reaction ${emoji} to message ${messageId}`)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update instance status in database
|
* Update instance status in database
|
||||||
*/
|
*/
|
||||||
|
|||||||
314
server/services/media/downloader.ts
Normal file
314
server/services/media/downloader.ts
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
/**
|
||||||
|
* Media Downloader Service
|
||||||
|
* Downloads and caches media from WhatsApp messages using Baileys
|
||||||
|
*/
|
||||||
|
import { downloadMediaMessage, type WAMessage } from '@whiskeysockets/baileys'
|
||||||
|
import { promises as fs } from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { query } from '../../utils/database'
|
||||||
|
import { baileysManager } from '../baileys/manager'
|
||||||
|
|
||||||
|
// Storage directory for cached media
|
||||||
|
const STORAGE_DIR = process.env.MEDIA_STORAGE_PATH || './storage/media'
|
||||||
|
|
||||||
|
// MIME type to extension mapping
|
||||||
|
const MIME_EXTENSIONS: Record<string, string> = {
|
||||||
|
'image/jpeg': 'jpg',
|
||||||
|
'image/png': 'png',
|
||||||
|
'image/gif': 'gif',
|
||||||
|
'image/webp': 'webp',
|
||||||
|
'video/mp4': 'mp4',
|
||||||
|
'video/3gpp': '3gp',
|
||||||
|
'video/quicktime': 'mov',
|
||||||
|
'audio/ogg': 'ogg',
|
||||||
|
'audio/mpeg': 'mp3',
|
||||||
|
'audio/mp4': 'm4a',
|
||||||
|
'audio/aac': 'aac',
|
||||||
|
'audio/opus': 'opus',
|
||||||
|
'application/pdf': 'pdf',
|
||||||
|
'application/msword': 'doc',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
|
||||||
|
'application/vnd.ms-excel': 'xls',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
|
||||||
|
'application/vnd.ms-powerpoint': 'ppt',
|
||||||
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
|
||||||
|
'application/zip': 'zip',
|
||||||
|
'application/x-rar-compressed': 'rar',
|
||||||
|
'text/plain': 'txt',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file extension from MIME type
|
||||||
|
*/
|
||||||
|
function getExtensionFromMime(mimetype: string): string {
|
||||||
|
return MIME_EXTENSIONS[mimetype] || mimetype.split('/')[1] || 'bin'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure storage directory exists
|
||||||
|
*/
|
||||||
|
async function ensureStorageDir(instanceId: string): Promise<string> {
|
||||||
|
const dir = path.join(STORAGE_DIR, instanceId)
|
||||||
|
await fs.mkdir(dir, { recursive: true })
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get message type from raw message
|
||||||
|
*/
|
||||||
|
function getMediaType(rawMessage: any): string | null {
|
||||||
|
const msg = rawMessage?.message
|
||||||
|
if (!msg) return null
|
||||||
|
|
||||||
|
if (msg.imageMessage) return 'image'
|
||||||
|
if (msg.videoMessage) return 'video'
|
||||||
|
if (msg.audioMessage) return 'audio'
|
||||||
|
if (msg.documentMessage) return 'document'
|
||||||
|
if (msg.stickerMessage) return 'sticker'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get MIME type from raw message
|
||||||
|
*/
|
||||||
|
function getMimeType(rawMessage: any, mediaType: string): string {
|
||||||
|
const msg = rawMessage?.message
|
||||||
|
if (!msg) return 'application/octet-stream'
|
||||||
|
|
||||||
|
const messageData = msg[`${mediaType}Message`]
|
||||||
|
return messageData?.mimetype || 'application/octet-stream'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download media from a message and cache it locally
|
||||||
|
*/
|
||||||
|
export async function downloadAndCacheMedia(
|
||||||
|
instanceId: string,
|
||||||
|
messageId: string
|
||||||
|
): Promise<{ path: string; mimetype: string; size: number } | null> {
|
||||||
|
// Get message from database
|
||||||
|
const result = await query(
|
||||||
|
`SELECT id, raw_message, message_type, media_cached, media_local_path, media_mimetype
|
||||||
|
FROM messages
|
||||||
|
WHERE instance_id = $1 AND message_id = $2`,
|
||||||
|
[instanceId, messageId]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
console.error(`[Media] Message not found: ${messageId}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = result.rows[0]
|
||||||
|
|
||||||
|
// If already cached, return cached path
|
||||||
|
if (message.media_cached && message.media_local_path) {
|
||||||
|
try {
|
||||||
|
await fs.access(message.media_local_path)
|
||||||
|
return {
|
||||||
|
path: message.media_local_path,
|
||||||
|
mimetype: message.media_mimetype || 'application/octet-stream',
|
||||||
|
size: 0 // Could read file size if needed
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist, re-download
|
||||||
|
console.log(`[Media] Cached file not found, re-downloading: ${messageId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawMessage = message.raw_message
|
||||||
|
if (!rawMessage?.message) {
|
||||||
|
console.error(`[Media] No raw message content for: ${messageId}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaType = getMediaType(rawMessage)
|
||||||
|
if (!mediaType) {
|
||||||
|
console.error(`[Media] Message is not a media message: ${messageId}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get socket for this instance
|
||||||
|
const socket = baileysManager.getSocket(instanceId)
|
||||||
|
if (!socket) {
|
||||||
|
console.error(`[Media] No active socket for instance: ${instanceId}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Download media using Baileys
|
||||||
|
console.log(`[Media] Downloading ${mediaType} for message: ${messageId}`)
|
||||||
|
|
||||||
|
const buffer = await downloadMediaMessage(
|
||||||
|
rawMessage as WAMessage,
|
||||||
|
'buffer',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
logger: console as any,
|
||||||
|
reuploadRequest: socket.updateMediaMessage
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!buffer || buffer.length === 0) {
|
||||||
|
console.error(`[Media] Empty buffer received for: ${messageId}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine file extension
|
||||||
|
const mimetype = getMimeType(rawMessage, mediaType)
|
||||||
|
const extension = getExtensionFromMime(mimetype)
|
||||||
|
|
||||||
|
// Ensure storage directory exists
|
||||||
|
const storageDir = await ensureStorageDir(instanceId)
|
||||||
|
|
||||||
|
// Save file
|
||||||
|
const filename = `${messageId}.${extension}`
|
||||||
|
const filePath = path.join(storageDir, filename)
|
||||||
|
|
||||||
|
await fs.writeFile(filePath, buffer)
|
||||||
|
|
||||||
|
// Update database with cache info
|
||||||
|
await query(
|
||||||
|
`UPDATE messages
|
||||||
|
SET media_cached = TRUE,
|
||||||
|
media_local_path = $1,
|
||||||
|
media_mimetype = $2,
|
||||||
|
media_size_bytes = $3
|
||||||
|
WHERE instance_id = $4 AND message_id = $5`,
|
||||||
|
[filePath, mimetype, buffer.length, instanceId, messageId]
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log(`[Media] Cached: ${filePath} (${buffer.length} bytes)`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: filePath,
|
||||||
|
mimetype,
|
||||||
|
size: buffer.length
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Media] Error downloading media for ${messageId}:`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached media path for a message
|
||||||
|
* Returns null if not cached
|
||||||
|
*/
|
||||||
|
export async function getCachedMediaPath(
|
||||||
|
instanceId: string,
|
||||||
|
messageId: string
|
||||||
|
): Promise<{ path: string; mimetype: string } | null> {
|
||||||
|
const result = await query(
|
||||||
|
`SELECT media_local_path, media_mimetype
|
||||||
|
FROM messages
|
||||||
|
WHERE instance_id = $1 AND message_id = $2 AND media_cached = TRUE`,
|
||||||
|
[instanceId, messageId]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.rows.length === 0 || !result.rows[0].media_local_path) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file exists
|
||||||
|
try {
|
||||||
|
await fs.access(result.rows[0].media_local_path)
|
||||||
|
return {
|
||||||
|
path: result.rows[0].media_local_path,
|
||||||
|
mimetype: result.rows[0].media_mimetype || 'application/octet-stream'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist, clear cache flag
|
||||||
|
await query(
|
||||||
|
`UPDATE messages SET media_cached = FALSE, media_local_path = NULL
|
||||||
|
WHERE instance_id = $1 AND message_id = $2`,
|
||||||
|
[instanceId, messageId]
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete cached media for a message
|
||||||
|
*/
|
||||||
|
export async function deleteCachedMedia(
|
||||||
|
instanceId: string,
|
||||||
|
messageId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await query(
|
||||||
|
`SELECT media_local_path FROM messages
|
||||||
|
WHERE instance_id = $1 AND message_id = $2 AND media_cached = TRUE`,
|
||||||
|
[instanceId, messageId]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.rows.length === 0 || !result.rows[0].media_local_path) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.unlink(result.rows[0].media_local_path)
|
||||||
|
} catch {
|
||||||
|
// File might not exist, that's ok
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE messages
|
||||||
|
SET media_cached = FALSE, media_local_path = NULL, media_size_bytes = NULL
|
||||||
|
WHERE instance_id = $1 AND message_id = $2`,
|
||||||
|
[instanceId, messageId]
|
||||||
|
)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total size of cached media for an instance
|
||||||
|
*/
|
||||||
|
export async function getCacheStats(instanceId: string): Promise<{
|
||||||
|
count: number
|
||||||
|
totalSize: number
|
||||||
|
}> {
|
||||||
|
const result = await query(
|
||||||
|
`SELECT COUNT(*) as count, COALESCE(SUM(media_size_bytes), 0) as total_size
|
||||||
|
FROM messages
|
||||||
|
WHERE instance_id = $1 AND media_cached = TRUE`,
|
||||||
|
[instanceId]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
count: parseInt(result.rows[0].count) || 0,
|
||||||
|
totalSize: parseInt(result.rows[0].total_size) || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all cached media for an instance
|
||||||
|
*/
|
||||||
|
export async function clearInstanceCache(instanceId: string): Promise<number> {
|
||||||
|
// Get all cached files
|
||||||
|
const result = await query(
|
||||||
|
`SELECT media_local_path FROM messages
|
||||||
|
WHERE instance_id = $1 AND media_cached = TRUE AND media_local_path IS NOT NULL`,
|
||||||
|
[instanceId]
|
||||||
|
)
|
||||||
|
|
||||||
|
let deleted = 0
|
||||||
|
for (const row of result.rows) {
|
||||||
|
try {
|
||||||
|
await fs.unlink(row.media_local_path)
|
||||||
|
deleted++
|
||||||
|
} catch {
|
||||||
|
// File might not exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear cache flags
|
||||||
|
await query(
|
||||||
|
`UPDATE messages
|
||||||
|
SET media_cached = FALSE, media_local_path = NULL, media_size_bytes = NULL
|
||||||
|
WHERE instance_id = $1`,
|
||||||
|
[instanceId]
|
||||||
|
)
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user