From 80d0042c7e8a4a100ebda5ae6eb3a3cde6f26094 Mon Sep 17 00:00:00 2001 From: josedario87 Date: Tue, 2 Dec 2025 21:21:33 -0600 Subject: [PATCH] =?UTF-8?q?Feature:=20Agregar=20bot=C3=B3n=20para=20crear?= =?UTF-8?q?=20webhook=20de=20debug=20autom=C3=A1ticamente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../debug/WebhookReceiverSection.vue | 92 +++++ app/components/messages/MediaPreview.vue | 172 ++++++++ app/components/messages/MessageBubble.vue | 305 +++++++++++--- app/components/messages/MessageInput.vue | 306 ++++++++++++-- .../messages/content/MessageAudio.vue | 178 +++++++++ .../messages/content/MessageContact.vue | 152 +++++++ .../messages/content/MessageDocument.vue | 213 ++++++++++ .../messages/content/MessageImage.vue | 174 ++++++++ .../messages/content/MessageLocation.vue | 118 ++++++ .../messages/content/MessageQuoted.vue | 78 ++++ .../messages/content/MessageSticker.vue | 56 +++ .../messages/content/MessageVideo.vue | 150 +++++++ app/composables/useAudioRecorder.ts | 245 ++++++++++++ app/pages/messages/index.vue | 123 +++++- app/types/message.ts | 373 ++++++++++++++++++ .../api/media/[instanceId]/[messageId].get.ts | 78 ++++ .../[instanceId]/[chatId]/index.get.ts | 271 +++++++++++-- .../[instanceId]/[chatId]/send-media.post.ts | 234 +++++++++++ .../migrations/002_messages_enhanced.sql | 85 ++++ server/services/baileys/manager.ts | 117 ++++++ server/services/media/downloader.ts | 314 +++++++++++++++ 21 files changed, 3722 insertions(+), 112 deletions(-) create mode 100644 app/components/messages/MediaPreview.vue create mode 100644 app/components/messages/content/MessageAudio.vue create mode 100644 app/components/messages/content/MessageContact.vue create mode 100644 app/components/messages/content/MessageDocument.vue create mode 100644 app/components/messages/content/MessageImage.vue create mode 100644 app/components/messages/content/MessageLocation.vue create mode 100644 app/components/messages/content/MessageQuoted.vue create mode 100644 app/components/messages/content/MessageSticker.vue create mode 100644 app/components/messages/content/MessageVideo.vue create mode 100644 app/composables/useAudioRecorder.ts create mode 100644 app/types/message.ts create mode 100644 server/api/media/[instanceId]/[messageId].get.ts create mode 100644 server/api/messages/[instanceId]/[chatId]/send-media.post.ts create mode 100644 server/database/migrations/002_messages_enhanced.sql create mode 100644 server/services/media/downloader.ts diff --git a/app/components/debug/WebhookReceiverSection.vue b/app/components/debug/WebhookReceiverSection.vue index 4a0fa52..4ae6f4b 100644 --- a/app/components/debug/WebhookReceiverSection.vue +++ b/app/components/debug/WebhookReceiverSection.vue @@ -18,6 +18,32 @@ + + +
+ + + Crear Webhook de Debug + +
+ + Webhook de debug activo + + Eliminar + +
+
@@ -111,9 +137,29 @@ interface WebhookEvent { headers: Record } +interface Webhook { + id: string + name: string + url: string + events: string[] +} + const events = ref([]) const loading = ref(false) const expandedEvents = ref(new Set()) +const creatingWebhook = ref(false) +const debugWebhookId = ref(null) + +// All available event types +const allEventTypes = [ + 'message.received', + 'message.sent', + 'message.status', + 'instance.connected', + 'instance.disconnected', + 'instance.status', + 'instance.qr' +] // Build receiver URL const receiverUrl = computed(() => { @@ -123,6 +169,51 @@ const receiverUrl = computed(() => { return '/api/debug/webhook-receiver' }) +const debugWebhookExists = computed(() => !!debugWebhookId.value) + +// Check if debug webhook already exists +const checkDebugWebhook = async () => { + try { + const webhooks = await $fetch('/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 () => { loading.value = true try { @@ -172,6 +263,7 @@ const copyToClipboard = async (text: string) => { // Fetch events on mount and set up polling onMounted(() => { + checkDebugWebhook() fetchEvents() // Poll every 5 seconds diff --git a/app/components/messages/MediaPreview.vue b/app/components/messages/MediaPreview.vue new file mode 100644 index 0000000..1370ad5 --- /dev/null +++ b/app/components/messages/MediaPreview.vue @@ -0,0 +1,172 @@ + + + diff --git a/app/components/messages/MessageBubble.vue b/app/components/messages/MessageBubble.vue index f8e5fa8..a1fce7e 100644 --- a/app/components/messages/MessageBubble.vue +++ b/app/components/messages/MessageBubble.vue @@ -1,33 +1,136 @@ + + diff --git a/app/components/messages/MessageInput.vue b/app/components/messages/MessageInput.vue index 223057f..797aacc 100644 --- a/app/components/messages/MessageInput.vue +++ b/app/components/messages/MessageInput.vue @@ -1,45 +1,301 @@ diff --git a/app/components/messages/content/MessageAudio.vue b/app/components/messages/content/MessageAudio.vue new file mode 100644 index 0000000..81c3c79 --- /dev/null +++ b/app/components/messages/content/MessageAudio.vue @@ -0,0 +1,178 @@ + + + diff --git a/app/components/messages/content/MessageContact.vue b/app/components/messages/content/MessageContact.vue new file mode 100644 index 0000000..f42f337 --- /dev/null +++ b/app/components/messages/content/MessageContact.vue @@ -0,0 +1,152 @@ + + + diff --git a/app/components/messages/content/MessageDocument.vue b/app/components/messages/content/MessageDocument.vue new file mode 100644 index 0000000..4589084 --- /dev/null +++ b/app/components/messages/content/MessageDocument.vue @@ -0,0 +1,213 @@ + + + diff --git a/app/components/messages/content/MessageImage.vue b/app/components/messages/content/MessageImage.vue new file mode 100644 index 0000000..84cbc76 --- /dev/null +++ b/app/components/messages/content/MessageImage.vue @@ -0,0 +1,174 @@ + + + diff --git a/app/components/messages/content/MessageLocation.vue b/app/components/messages/content/MessageLocation.vue new file mode 100644 index 0000000..7318758 --- /dev/null +++ b/app/components/messages/content/MessageLocation.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/app/components/messages/content/MessageQuoted.vue b/app/components/messages/content/MessageQuoted.vue new file mode 100644 index 0000000..22ebc58 --- /dev/null +++ b/app/components/messages/content/MessageQuoted.vue @@ -0,0 +1,78 @@ + + + diff --git a/app/components/messages/content/MessageSticker.vue b/app/components/messages/content/MessageSticker.vue new file mode 100644 index 0000000..65d2543 --- /dev/null +++ b/app/components/messages/content/MessageSticker.vue @@ -0,0 +1,56 @@ + + + diff --git a/app/components/messages/content/MessageVideo.vue b/app/components/messages/content/MessageVideo.vue new file mode 100644 index 0000000..81c01e9 --- /dev/null +++ b/app/components/messages/content/MessageVideo.vue @@ -0,0 +1,150 @@ + + + diff --git a/app/composables/useAudioRecorder.ts b/app/composables/useAudioRecorder.ts new file mode 100644 index 0000000..0b2f5e1 --- /dev/null +++ b/app/composables/useAudioRecorder.ts @@ -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({ + 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 => { + 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 + } +} diff --git a/app/pages/messages/index.vue b/app/pages/messages/index.vue index 5947b88..c1daafb 100644 --- a/app/pages/messages/index.vue +++ b/app/pages/messages/index.vue @@ -161,17 +161,26 @@ -
+
@@ -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(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 const copyToClipboard = async (text: string) => { 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(null) + +const handleSendMessage = async (content: string, files: File[], caption: string, quotedId?: string) => { if (!selectedInstance.value?.value || !selectedChat.value) return try { - await $fetch(`/api/messages/${selectedInstance.value.value}/${selectedChat.value.id}/send`, { - method: 'POST', - body: { content } - }) + const instanceId = selectedInstance.value.value + const chatId = selectedChat.value.id + + // 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 - messages.value = await $fetch(`/api/messages/${selectedInstance.value.value}/${selectedChat.value.id}`) + messages.value = await $fetch(`/api/messages/${instanceId}/${chatId}`) } catch (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 const reloadChats = async () => { if (!selectedInstance.value?.value) return diff --git a/app/types/message.ts b/app/types/message.ts new file mode 100644 index 0000000..7958b78 --- /dev/null +++ b/app/types/message.ts @@ -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 +} + +/** + * 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 = { + 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 = { + 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')}` +} diff --git a/server/api/media/[instanceId]/[messageId].get.ts b/server/api/media/[instanceId]/[messageId].get.ts new file mode 100644 index 0000000..f7d0200 --- /dev/null +++ b/server/api/media/[instanceId]/[messageId].get.ts @@ -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' + }) + } +}) diff --git a/server/api/messages/[instanceId]/[chatId]/index.get.ts b/server/api/messages/[instanceId]/[chatId]/index.get.ts index d2bf520..f261f90 100644 --- a/server/api/messages/[instanceId]/[chatId]/index.get.ts +++ b/server/api/messages/[instanceId]/[chatId]/index.get.ts @@ -1,6 +1,6 @@ /** * 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' @@ -15,6 +15,201 @@ interface MessageRow { media_url: string | null timestamp: Date 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) => { @@ -30,26 +225,38 @@ export default defineEventHandler(async (event) => { const queryParams = getQuery(event) const limit = Math.min(parseInt(queryParams.limit as string) || 50, 100) const offset = parseInt(queryParams.offset as string) || 0 + const before = queryParams.before as string // For infinite scroll - // Verify chat exists and belongs to instance - const chatCheck = await query( - 'SELECT id FROM chats WHERE id = $1 AND instance_id = $2', + // Verify chat exists and get info + const chatCheck = await query( + 'SELECT id, is_group FROM chats WHERE id = $1 AND instance_id = $2', [chatId, instanceId] ) if (chatCheck.rows.length === 0) { throw createError({ statusCode: 404, message: 'Chat not found' }) } - // Get messages - const result = await query( - `SELECT id, message_id, from_jid, from_me, message_type, - content, caption, media_url, timestamp, status - FROM messages - WHERE chat_id = $1 - ORDER BY timestamp DESC - LIMIT $2 OFFSET $3`, - [chatId, limit, offset] - ) + const isGroup = chatCheck.rows[0].is_group + + // Build query with optional before parameter for infinite scroll + let messagesQuery = ` + SELECT id, message_id, from_jid, from_me, message_type, + content, caption, media_url, timestamp, status, + raw_message, participant_jid, push_name, quoted_message_id + FROM messages + 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(messagesQuery, params) // Mark as read await query( @@ -57,16 +264,28 @@ export default defineEventHandler(async (event) => { [chatId] ) - return result.rows.map(row => ({ - id: row.id, - messageId: row.message_id, - fromJid: row.from_jid, - fromMe: row.from_me, - type: row.message_type, - content: row.content, - caption: row.caption, - mediaUrl: row.media_url, - timestamp: row.timestamp, - status: row.status - })) + // Parse and return messages + return result.rows.map(row => { + const parsedData = parseRawMessage(row.raw_message, row.message_type) + + return { + id: row.id, + messageId: row.message_id, + chatId: chatId, + fromJid: row.from_jid, + fromMe: row.from_me, + 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 + } + }) }) diff --git a/server/api/messages/[instanceId]/[chatId]/send-media.post.ts b/server/api/messages/[instanceId]/[chatId]/send-media.post.ts new file mode 100644 index 0000000..12342c1 --- /dev/null +++ b/server/api/messages/[instanceId]/[chatId]/send-media.post.ts @@ -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] + ) +} diff --git a/server/database/migrations/002_messages_enhanced.sql b/server/database/migrations/002_messages_enhanced.sql new file mode 100644 index 0000000..9e8d468 --- /dev/null +++ b/server/database/migrations/002_messages_enhanced.sql @@ -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); diff --git a/server/services/baileys/manager.ts b/server/services/baileys/manager.ts index 6e84df5..d3b7d88 100644 --- a/server/services/baileys/manager.ts +++ b/server/services/baileys/manager.ts @@ -47,6 +47,8 @@ export interface InstanceEvents { 'message.received': { instanceId: string; message: any } 'message.sent': { instanceId: string; message: any } 'message.status': { instanceId: string; messageId: string; status: string } + 'message.reaction': { instanceId: string; reaction: any } + 'presence.update': { instanceId: string; jid: string; presences: Record } } 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 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}`) @@ -477,6 +547,53 @@ class BaileysManager extends EventEmitter { return managed.socket } + /** + * Subscribe to presence updates for a JID + */ + async subscribeToPresence(instanceId: string, jid: string): Promise { + 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 { + 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 { + 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 */ diff --git a/server/services/media/downloader.ts b/server/services/media/downloader.ts new file mode 100644 index 0000000..3572802 --- /dev/null +++ b/server/services/media/downloader.ts @@ -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 = { + '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 { + 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 { + 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 { + // 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 +}