From cb846d0c56d67bc3a09de1d9f1bbf3095cac6602 Mon Sep 17 00:00:00 2001 From: josedario87 Date: Thu, 4 Dec 2025 12:06:35 -0600 Subject: [PATCH] =?UTF-8?q?Feat:=20Agregar=20soporte=20para=20env=C3=ADo?= =?UTF-8?q?=20de=20Contacts,=20Polls=20y=20Events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: Nuevo soporte en endpoint /send para tipos contact, poll, event - UI: Modales para crear y enviar contactos, encuestas y eventos - Visualización: Componentes MessagePoll y MessageEvent para mostrar mensajes recibidos - Tipos: Agregar PollInfo, EventInfo y tipo 'event' a MessageType --- app/components/messages/ContactSendModal.vue | 150 +++++++ app/components/messages/EventSendModal.vue | 257 ++++++++++++ app/components/messages/MessageBubble.vue | 16 + app/components/messages/MessageInput.vue | 81 ++++ app/components/messages/PollSendModal.vue | 201 +++++++++ .../messages/content/MessageEvent.vue | 126 ++++++ .../messages/content/MessagePoll.vue | 108 +++++ app/pages/messages/index.vue | 118 ++++++ app/types/message.ts | 51 +++ .../[instanceId]/[chatId]/send.post.ts | 381 +++++++++++++++--- 10 files changed, 1443 insertions(+), 46 deletions(-) create mode 100644 app/components/messages/ContactSendModal.vue create mode 100644 app/components/messages/EventSendModal.vue create mode 100644 app/components/messages/PollSendModal.vue create mode 100644 app/components/messages/content/MessageEvent.vue create mode 100644 app/components/messages/content/MessagePoll.vue diff --git a/app/components/messages/ContactSendModal.vue b/app/components/messages/ContactSendModal.vue new file mode 100644 index 0000000..01d772f --- /dev/null +++ b/app/components/messages/ContactSendModal.vue @@ -0,0 +1,150 @@ + + + diff --git a/app/components/messages/EventSendModal.vue b/app/components/messages/EventSendModal.vue new file mode 100644 index 0000000..f5ae049 --- /dev/null +++ b/app/components/messages/EventSendModal.vue @@ -0,0 +1,257 @@ + + + diff --git a/app/components/messages/MessageBubble.vue b/app/components/messages/MessageBubble.vue index 7ae6eab..a3681f9 100644 --- a/app/components/messages/MessageBubble.vue +++ b/app/components/messages/MessageBubble.vue @@ -135,6 +135,20 @@ :from-me="message.fromMe" /> + + + + + +
Suelta los archivos aquí

+ + + + + + + + + @@ -190,9 +208,37 @@ const props = withDefaults(defineProps(), { replyingTo: null }) +interface ContactInfo { + displayName: string + phoneNumber: string + organization?: string +} + +interface PollData { + name: string + options: string[] + selectableCount: number +} + +interface EventData { + name: string + startDate: string + endDate?: string + description?: string + location?: { + name?: string + address?: string + latitude?: number + longitude?: number + } +} + const emit = defineEmits<{ send: [content: string, files: File[], caption: string, quotedId?: string, stickerModes?: boolean[]] sendVoice: [audioFile: File] + sendContact: [contacts: ContactInfo[], quotedId?: string] + sendPoll: [poll: PollData, quotedId?: string] + sendEvent: [event: EventData, quotedId?: string] cancelReply: [] typing: [] recording: [isRecording: boolean] @@ -212,6 +258,11 @@ const isDragging = ref(false) const stickerModes = ref([]) const showDebug = ref(false) +// Modal states +const showContactModal = ref(false) +const showPollModal = ref(false) +const showEventModal = ref(false) + // File size limits (in bytes) - should match server const MAX_SIZES: Record = { image: 16 * 1024 * 1024, // 16 MB @@ -317,6 +368,21 @@ const attachmentMenuItems = [ label: 'Documento', icon: 'i-lucide-file', onSelect: () => documentInput.value?.click() + }], + [{ + label: 'Contacto', + icon: 'i-lucide-user', + onSelect: () => showContactModal.value = true + }], + [{ + label: 'Encuesta', + icon: 'i-lucide-bar-chart-2', + onSelect: () => showPollModal.value = true + }], + [{ + label: 'Evento', + icon: 'i-lucide-calendar', + onSelect: () => showEventModal.value = true }] ] @@ -441,5 +507,20 @@ const formatDuration = (seconds: number): string => { const getTypePlaceholder = (type: MessageType): string => { return getMessageTypePlaceholder(type) } + +// Handle contact send from modal +const handleSendContact = (contacts: ContactInfo[]) => { + emit('sendContact', contacts, props.replyingTo?.messageId) +} + +// Handle poll send from modal +const handleSendPoll = (poll: PollData) => { + emit('sendPoll', poll, props.replyingTo?.messageId) +} + +// Handle event send from modal +const handleSendEvent = (eventData: EventData) => { + emit('sendEvent', eventData, props.replyingTo?.messageId) +} diff --git a/app/components/messages/PollSendModal.vue b/app/components/messages/PollSendModal.vue new file mode 100644 index 0000000..d579455 --- /dev/null +++ b/app/components/messages/PollSendModal.vue @@ -0,0 +1,201 @@ + + + diff --git a/app/components/messages/content/MessageEvent.vue b/app/components/messages/content/MessageEvent.vue new file mode 100644 index 0000000..fb290ff --- /dev/null +++ b/app/components/messages/content/MessageEvent.vue @@ -0,0 +1,126 @@ + + + diff --git a/app/components/messages/content/MessagePoll.vue b/app/components/messages/content/MessagePoll.vue new file mode 100644 index 0000000..229af46 --- /dev/null +++ b/app/components/messages/content/MessagePoll.vue @@ -0,0 +1,108 @@ + + + diff --git a/app/pages/messages/index.vue b/app/pages/messages/index.vue index 38262fb..e27b8e9 100644 --- a/app/pages/messages/index.vue +++ b/app/pages/messages/index.vue @@ -231,6 +231,9 @@ :replying-to="replyingTo" @send="handleSendMessage" @send-voice="handleSendVoice" + @send-contact="handleSendContact" + @send-poll="handleSendPoll" + @send-event="handleSendEvent" @cancel-reply="replyingTo = null" @typing="handleTyping" @recording="handleRecordingPresence" @@ -647,6 +650,121 @@ const handleRecordingPresence = (isRecording: boolean) => { } } +// Handle contact send +interface ContactInfo { + displayName: string + phoneNumber: string + organization?: string +} + +const handleSendContact = async (contacts: ContactInfo[], quotedId?: string) => { + if (!selectedInstance.value?.value || !selectedChat.value) return + + try { + const instanceId = selectedInstance.value.value + const chatId = selectedChat.value.id + + await $fetch(`/api/messages/${instanceId}/${chatId}/send`, { + method: 'POST', + body: { + type: 'contact', + contacts, + quotedMessageId: quotedId || replyingTo.value?.messageId + } + }) + + replyingTo.value = null + messages.value = await $fetch(`/api/messages/${instanceId}/${chatId}`) + } catch (e: any) { + console.error('Error sending contact:', e) + toast.add({ + title: 'Error de envío', + description: e?.data?.message || e?.message || 'Error al enviar el contacto', + color: 'error', + duration: 5000 + }) + } +} + +// Handle poll send +interface PollData { + name: string + options: string[] + selectableCount: number +} + +const handleSendPoll = async (poll: PollData, quotedId?: string) => { + if (!selectedInstance.value?.value || !selectedChat.value) return + + try { + const instanceId = selectedInstance.value.value + const chatId = selectedChat.value.id + + await $fetch(`/api/messages/${instanceId}/${chatId}/send`, { + method: 'POST', + body: { + type: 'poll', + ...poll, + quotedMessageId: quotedId || replyingTo.value?.messageId + } + }) + + replyingTo.value = null + messages.value = await $fetch(`/api/messages/${instanceId}/${chatId}`) + } catch (e: any) { + console.error('Error sending poll:', e) + toast.add({ + title: 'Error de envío', + description: e?.data?.message || e?.message || 'Error al enviar la encuesta', + color: 'error', + duration: 5000 + }) + } +} + +// Handle event send +interface EventData { + name: string + startDate: string + endDate?: string + description?: string + location?: { + name?: string + address?: string + latitude?: number + longitude?: number + } +} + +const handleSendEvent = async (eventData: EventData, quotedId?: string) => { + if (!selectedInstance.value?.value || !selectedChat.value) return + + try { + const instanceId = selectedInstance.value.value + const chatId = selectedChat.value.id + + await $fetch(`/api/messages/${instanceId}/${chatId}/send`, { + method: 'POST', + body: { + type: 'event', + ...eventData, + quotedMessageId: quotedId || replyingTo.value?.messageId + } + }) + + replyingTo.value = null + messages.value = await $fetch(`/api/messages/${instanceId}/${chatId}`) + } catch (e: any) { + console.error('Error sending event:', e) + toast.add({ + title: 'Error de envío', + description: e?.data?.message || e?.message || 'Error al enviar el evento', + color: 'error', + duration: 5000 + }) + } +} + // 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 index 7958b78..7c268b0 100644 --- a/app/types/message.ts +++ b/app/types/message.ts @@ -15,6 +15,7 @@ export type MessageType = | 'location' | 'reaction' | 'poll' + | 'event' | 'unknown' // Estados de mensaje @@ -79,6 +80,50 @@ export interface ContactInfo { phones?: string[] } +/** + * Información de encuesta + */ +export interface PollInfo { + /** Nombre/pregunta de la encuesta */ + name: string + /** Opciones de la encuesta */ + options: string[] + /** Votos por opción */ + votes?: number[] + /** Cantidad máxima de selecciones permitidas */ + selectableCount?: number +} + +/** + * Información de ubicación de evento + */ +export interface EventLocationInfo { + /** Nombre del lugar */ + name?: string + /** Dirección */ + address?: string + /** Latitud */ + latitude?: number + /** Longitud */ + longitude?: number +} + +/** + * Información de evento + */ +export interface EventInfo { + /** Nombre del evento */ + name: string + /** Fecha y hora de inicio */ + startDate: string + /** Fecha y hora de fin */ + endDate?: string + /** Descripción del evento */ + description?: string + /** Ubicación del evento */ + location?: EventLocationInfo +} + /** * Información de mensaje citado (quoted/reply) */ @@ -139,6 +184,10 @@ export interface Message { location?: LocationInfo /** Información de contacto */ contact?: ContactInfo + /** Información de encuesta */ + poll?: PollInfo + /** Información de evento */ + event?: EventInfo /** Mensaje citado */ quoted?: QuotedMessage /** Reacciones al mensaje */ @@ -304,6 +353,7 @@ export function getMessageTypePlaceholder(type: MessageType): string { location: 'Ubicación', reaction: 'Reacción', poll: 'Encuesta', + event: 'Evento', unknown: 'Mensaje' } return placeholders[type] || 'Mensaje' @@ -324,6 +374,7 @@ export function getMessageTypeIcon(type: MessageType): string { location: 'i-lucide-map-pin', reaction: 'i-lucide-heart', poll: 'i-lucide-bar-chart', + event: 'i-lucide-calendar', unknown: 'i-lucide-help-circle' } return icons[type] || 'i-lucide-message-square' diff --git a/server/api/messages/[instanceId]/[chatId]/send.post.ts b/server/api/messages/[instanceId]/[chatId]/send.post.ts index 999a710..a4de526 100644 --- a/server/api/messages/[instanceId]/[chatId]/send.post.ts +++ b/server/api/messages/[instanceId]/[chatId]/send.post.ts @@ -33,6 +33,43 @@ interface TextMessageBody { quotedMessageId?: string } +interface ContactInfo { + displayName: string + phoneNumber: string + organization?: string +} + +interface ContactMessageBody { + type: 'contact' + contacts: ContactInfo[] + quotedMessageId?: string +} + +interface PollMessageBody { + type: 'poll' + name: string + options: string[] + selectableCount?: number + quotedMessageId?: string +} + +interface EventMessageBody { + type: 'event' + name: string + startDate: string // ISO date string + endDate?: string + description?: string + location?: { + name?: string + address?: string + latitude?: number + longitude?: number + } + quotedMessageId?: string +} + +type JsonMessageBody = TextMessageBody | ContactMessageBody | PollMessageBody | EventMessageBody + export default defineEventHandler(async (event) => { const username = getHeader(event, 'x-authentik-username') if (!username) { @@ -71,80 +108,107 @@ export default defineEventHandler(async (event) => { // ==================== MEDIA / STICKER ==================== return await handleMediaMessage(event, instanceId, chatId, jid, socket) } else { - // ==================== TEXT ==================== - return await handleTextMessage(event, instanceId, chatId, jid) + // ==================== JSON MESSAGES ==================== + return await handleJsonMessage(event, instanceId, chatId, jid, socket) } }) /** - * Handle text message sending + * Handle JSON message sending (text, contacts, polls, events) */ -async function handleTextMessage( +async function handleJsonMessage( event: any, instanceId: string, chatId: string, - jid: string + jid: string, + socket: any ) { - const body = await readBody(event) - const messageText = body.content || body.message + const body = await readBody(event) - if (!messageText?.trim()) { - throw createError({ statusCode: 400, message: 'Message content is required' }) - } + // Determine message type + const messageType = (body as any).type || 'text' // Get quoted message if provided let quotedMessage = null - if (body.quotedMessageId) { + const quotedMessageId = (body as any).quotedMessageId + if (quotedMessageId) { const quotedResult = await query( 'SELECT raw_message FROM messages WHERE message_id = $1 AND instance_id = $2', - [body.quotedMessageId, instanceId] + [quotedMessageId, instanceId] ) if (quotedResult.rows.length > 0) { quotedMessage = quotedResult.rows[0].raw_message } } + const options: any = {} + if (quotedMessage) { + options.quoted = quotedMessage + } + try { - const content = { text: messageText } - const options: any = {} - if (quotedMessage) { - options.quoted = quotedMessage - } + let content: any + let dbMessageType: string + let dbContent: string - const result = await baileysManager.sendMessage(instanceId, jid, content, options) + switch (messageType) { + case 'contact': + return await handleContactMessage(body as ContactMessageBody, instanceId, chatId, jid, socket, options) - // Save to database - await query( - `INSERT INTO messages ( - instance_id, chat_id, message_id, from_jid, from_me, - message_type, content, timestamp, status, raw_message, quoted_message_id - ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8, $9, $10) - ON CONFLICT (instance_id, message_id) DO NOTHING`, - [ - instanceId, - chatId, - result.key.id, - 'me', - true, - 'text', - messageText, - 'sent', - JSON.stringify(result), - body.quotedMessageId || null - ] - ) + case 'poll': + return await handlePollMessage(body as PollMessageBody, instanceId, chatId, jid, socket, options) - await query( - `UPDATE chats SET last_message_at = NOW(), last_message_type = 'text' WHERE id = $1`, - [chatId] - ) + case 'event': + return await handleEventMessage(body as EventMessageBody, instanceId, chatId, jid, socket, options) - return { - success: true, - messages: [{ messageId: result.key.id, type: 'text' }] + default: + // Text message + const textBody = body as TextMessageBody + const messageText = textBody.content || textBody.message + + if (!messageText?.trim()) { + throw createError({ statusCode: 400, message: 'Message content is required' }) + } + + content = { text: messageText } + dbMessageType = 'text' + dbContent = messageText + + const result = await socket.sendMessage(jid, content, options) + + // Save to database + await query( + `INSERT INTO messages ( + instance_id, chat_id, message_id, from_jid, from_me, + message_type, content, timestamp, status, raw_message, quoted_message_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8, $9, $10) + ON CONFLICT (instance_id, message_id) DO NOTHING`, + [ + instanceId, + chatId, + result.key.id, + 'me', + true, + dbMessageType, + dbContent, + 'sent', + JSON.stringify(result), + quotedMessageId || null + ] + ) + + await query( + `UPDATE chats SET last_message_at = NOW(), last_message_type = $1 WHERE id = $2`, + [dbMessageType, chatId] + ) + + return { + success: true, + messages: [{ messageId: result.key.id, type: dbMessageType }] + } } } catch (error) { - console.error('[Send] Error sending text:', error) + console.error('[Send] Error sending message:', error) throw createError({ statusCode: 500, message: `Failed to send message: ${(error as Error).message}` @@ -152,6 +216,231 @@ async function handleTextMessage( } } +/** + * Handle contact message sending + */ +async function handleContactMessage( + body: ContactMessageBody, + instanceId: string, + chatId: string, + jid: string, + socket: any, + options: any +) { + if (!body.contacts || body.contacts.length === 0) { + throw createError({ statusCode: 400, message: 'At least one contact is required' }) + } + + // Build vCards for each contact + const vcards = body.contacts.map(contact => { + const lines = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + `FN:${contact.displayName}`, + `TEL;type=CELL;waid=${contact.phoneNumber.replace(/\D/g, '')}:${contact.phoneNumber}` + ] + + if (contact.organization) { + lines.push(`ORG:${contact.organization}`) + } + + lines.push('END:VCARD') + return lines.join('\n') + }) + + const content = { + contacts: { + displayName: body.contacts.length === 1 + ? body.contacts[0].displayName + : `${body.contacts.length} contactos`, + contacts: vcards.map(vcard => ({ vcard })) + } + } + + const result = await socket.sendMessage(jid, content, options) + + // Save to database + await query( + `INSERT INTO messages ( + instance_id, chat_id, message_id, from_jid, from_me, + message_type, content, timestamp, status, raw_message, quoted_message_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8, $9, $10) + ON CONFLICT (instance_id, message_id) DO NOTHING`, + [ + instanceId, + chatId, + result.key.id, + 'me', + true, + 'contact', + JSON.stringify(body.contacts), + 'sent', + JSON.stringify(result), + body.quotedMessageId || null + ] + ) + + await query( + `UPDATE chats SET last_message_at = NOW(), last_message_type = 'contact' WHERE id = $1`, + [chatId] + ) + + return { + success: true, + messages: [{ messageId: result.key.id, type: 'contact' }] + } +} + +/** + * Handle poll message sending + */ +async function handlePollMessage( + body: PollMessageBody, + instanceId: string, + chatId: string, + jid: string, + socket: any, + options: any +) { + if (!body.name?.trim()) { + throw createError({ statusCode: 400, message: 'Poll name is required' }) + } + + if (!body.options || body.options.length < 2) { + throw createError({ statusCode: 400, message: 'At least 2 poll options are required' }) + } + + if (body.options.length > 12) { + throw createError({ statusCode: 400, message: 'Maximum 12 poll options allowed' }) + } + + const content = { + poll: { + name: body.name, + values: body.options, + selectableCount: body.selectableCount || 1 + } + } + + const result = await socket.sendMessage(jid, content, options) + + // Save to database + await query( + `INSERT INTO messages ( + instance_id, chat_id, message_id, from_jid, from_me, + message_type, content, timestamp, status, raw_message, quoted_message_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8, $9, $10) + ON CONFLICT (instance_id, message_id) DO NOTHING`, + [ + instanceId, + chatId, + result.key.id, + 'me', + true, + 'poll', + JSON.stringify({ name: body.name, options: body.options, selectableCount: body.selectableCount || 1 }), + 'sent', + JSON.stringify(result), + body.quotedMessageId || null + ] + ) + + await query( + `UPDATE chats SET last_message_at = NOW(), last_message_type = 'poll' WHERE id = $1`, + [chatId] + ) + + return { + success: true, + messages: [{ messageId: result.key.id, type: 'poll' }] + } +} + +/** + * Handle event message sending + */ +async function handleEventMessage( + body: EventMessageBody, + instanceId: string, + chatId: string, + jid: string, + socket: any, + options: any +) { + if (!body.name?.trim()) { + throw createError({ statusCode: 400, message: 'Event name is required' }) + } + + if (!body.startDate) { + throw createError({ statusCode: 400, message: 'Event start date is required' }) + } + + const eventContent: any = { + name: body.name, + startDate: new Date(body.startDate) + } + + if (body.endDate) { + eventContent.endDate = new Date(body.endDate) + } + + if (body.description) { + eventContent.description = body.description + } + + if (body.location) { + if (body.location.latitude && body.location.longitude) { + eventContent.location = { + degreesLatitude: body.location.latitude, + degreesLongitude: body.location.longitude, + name: body.location.name, + address: body.location.address + } + } + } + + const content = { event: eventContent } + + const result = await socket.sendMessage(jid, content, options) + + // Save to database + await query( + `INSERT INTO messages ( + instance_id, chat_id, message_id, from_jid, from_me, + message_type, content, timestamp, status, raw_message, quoted_message_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8, $9, $10) + ON CONFLICT (instance_id, message_id) DO NOTHING`, + [ + instanceId, + chatId, + result.key.id, + 'me', + true, + 'event', + JSON.stringify({ + name: body.name, + startDate: body.startDate, + endDate: body.endDate, + description: body.description, + location: body.location + }), + 'sent', + JSON.stringify(result), + body.quotedMessageId || null + ] + ) + + await query( + `UPDATE chats SET last_message_at = NOW(), last_message_type = 'event' WHERE id = $1`, + [chatId] + ) + + return { + success: true, + messages: [{ messageId: result.key.id, type: 'event' }] + } +} + /** * Handle media/sticker message sending */