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

- 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:
2025-12-02 21:21:33 -06:00
parent 71593b25e9
commit 80d0042c7e
21 changed files with 3722 additions and 112 deletions

View File

@@ -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

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
}
}

View File

@@ -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
View 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')}`
}

View 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'
})
}
})

View File

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

View 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]
)
}

View 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);

View File

@@ -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
*/ */

View 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
}