Feat: Agregar soporte para envío de Contacts, Polls y Events
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m8s

- 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
This commit is contained in:
2025-12-04 12:06:35 -06:00
parent 48f23c512b
commit cb846d0c56
10 changed files with 1443 additions and 46 deletions

View File

@@ -0,0 +1,150 @@
<template>
<UModal v-model:open="isOpen">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-white">Enviar Contacto</h3>
<UButton
variant="ghost"
icon="i-lucide-x"
size="sm"
@click="isOpen = false"
/>
</div>
</template>
<div class="space-y-4">
<!-- Contacts list -->
<div
v-for="(contact, index) in contacts"
:key="index"
class="p-3 rounded-lg bg-[var(--wa-bg-light)] space-y-3"
>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-[var(--wa-text-muted)]">
Contacto {{ index + 1 }}
</span>
<UButton
v-if="contacts.length > 1"
variant="ghost"
icon="i-lucide-trash-2"
size="xs"
color="error"
@click="removeContact(index)"
/>
</div>
<UInput
v-model="contact.displayName"
placeholder="Nombre del contacto"
icon="i-lucide-user"
/>
<UInput
v-model="contact.phoneNumber"
placeholder="Número de teléfono (ej: +54911...)"
icon="i-lucide-phone"
/>
<UInput
v-model="contact.organization"
placeholder="Organización (opcional)"
icon="i-lucide-building"
/>
</div>
<!-- Add contact button -->
<UButton
v-if="contacts.length < 5"
variant="outline"
icon="i-lucide-plus"
block
@click="addContact"
>
Agregar otro contacto
</UButton>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton variant="ghost" @click="isOpen = false">
Cancelar
</UButton>
<UButton
:disabled="!isValid"
:loading="isSending"
@click="handleSend"
>
Enviar
</UButton>
</div>
</template>
</UCard>
</template>
</UModal>
</template>
<script setup lang="ts">
interface ContactInfo {
displayName: string
phoneNumber: string
organization?: string
}
const isOpen = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{
send: [contacts: ContactInfo[]]
}>()
const isSending = ref(false)
const contacts = ref<ContactInfo[]>([
{ displayName: '', phoneNumber: '', organization: '' }
])
const isValid = computed(() => {
return contacts.value.every(c =>
c.displayName.trim() && c.phoneNumber.trim()
)
})
const addContact = () => {
if (contacts.value.length < 5) {
contacts.value.push({ displayName: '', phoneNumber: '', organization: '' })
}
}
const removeContact = (index: number) => {
contacts.value.splice(index, 1)
}
const handleSend = async () => {
if (!isValid.value) return
isSending.value = true
try {
const validContacts = contacts.value.map(c => ({
displayName: c.displayName.trim(),
phoneNumber: c.phoneNumber.trim(),
organization: c.organization?.trim() || undefined
}))
emit('send', validContacts)
// Reset form
contacts.value = [{ displayName: '', phoneNumber: '', organization: '' }]
isOpen.value = false
} finally {
isSending.value = false
}
}
// Reset form when modal opens
watch(isOpen, (open) => {
if (open) {
contacts.value = [{ displayName: '', phoneNumber: '', organization: '' }]
}
})
</script>

View File

@@ -0,0 +1,257 @@
<template>
<UModal v-model:open="isOpen">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-white">Crear Evento</h3>
<UButton
variant="ghost"
icon="i-lucide-x"
size="sm"
@click="isOpen = false"
/>
</div>
</template>
<div class="space-y-4 max-h-[60vh] overflow-y-auto">
<!-- Event name -->
<div>
<label class="block text-sm font-medium text-[var(--wa-text-muted)] mb-1">
Nombre del evento *
</label>
<UInput
v-model="eventName"
placeholder="Reunión de equipo"
icon="i-lucide-calendar"
/>
</div>
<!-- Start date/time -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium text-[var(--wa-text-muted)] mb-1">
Fecha inicio *
</label>
<UInput
v-model="startDate"
type="date"
/>
</div>
<div>
<label class="block text-sm font-medium text-[var(--wa-text-muted)] mb-1">
Hora inicio *
</label>
<UInput
v-model="startTime"
type="time"
/>
</div>
</div>
<!-- End date/time (optional) -->
<div class="flex items-center gap-2 mb-2">
<UCheckbox v-model="hasEndDate" />
<span class="text-sm text-[var(--wa-text-muted)]">Agregar fecha de fin</span>
</div>
<div v-if="hasEndDate" class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium text-[var(--wa-text-muted)] mb-1">
Fecha fin
</label>
<UInput
v-model="endDate"
type="date"
/>
</div>
<div>
<label class="block text-sm font-medium text-[var(--wa-text-muted)] mb-1">
Hora fin
</label>
<UInput
v-model="endTime"
type="time"
/>
</div>
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-[var(--wa-text-muted)] mb-1">
Descripción (opcional)
</label>
<UTextarea
v-model="description"
placeholder="Detalles del evento..."
:rows="2"
/>
</div>
<!-- Location -->
<div class="flex items-center gap-2 mb-2">
<UCheckbox v-model="hasLocation" />
<span class="text-sm text-[var(--wa-text-muted)]">Agregar ubicación</span>
</div>
<div v-if="hasLocation" class="space-y-3 p-3 rounded-lg bg-[var(--wa-bg-light)]">
<UInput
v-model="locationName"
placeholder="Nombre del lugar"
icon="i-lucide-map-pin"
/>
<UInput
v-model="locationAddress"
placeholder="Dirección"
icon="i-lucide-navigation"
/>
<div class="grid grid-cols-2 gap-3">
<UInput
v-model.number="latitude"
type="number"
step="any"
placeholder="Latitud"
/>
<UInput
v-model.number="longitude"
type="number"
step="any"
placeholder="Longitud"
/>
</div>
<p class="text-xs text-[var(--wa-text-muted)]">
Las coordenadas son opcionales pero permiten mostrar el mapa
</p>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton variant="ghost" @click="isOpen = false">
Cancelar
</UButton>
<UButton
:disabled="!isValid"
:loading="isSending"
@click="handleSend"
>
Crear evento
</UButton>
</div>
</template>
</UCard>
</template>
</UModal>
</template>
<script setup lang="ts">
interface EventData {
name: string
startDate: string
endDate?: string
description?: string
location?: {
name?: string
address?: string
latitude?: number
longitude?: number
}
}
const isOpen = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{
send: [event: EventData]
}>()
const isSending = ref(false)
// Form fields
const eventName = ref('')
const startDate = ref('')
const startTime = ref('')
const hasEndDate = ref(false)
const endDate = ref('')
const endTime = ref('')
const description = ref('')
const hasLocation = ref(false)
const locationName = ref('')
const locationAddress = ref('')
const latitude = ref<number | undefined>(undefined)
const longitude = ref<number | undefined>(undefined)
const isValid = computed(() => {
return eventName.value.trim() && startDate.value && startTime.value
})
const handleSend = async () => {
if (!isValid.value) return
isSending.value = true
try {
const startDateTime = new Date(`${startDate.value}T${startTime.value}`)
const eventData: EventData = {
name: eventName.value.trim(),
startDate: startDateTime.toISOString()
}
if (hasEndDate.value && endDate.value && endTime.value) {
const endDateTime = new Date(`${endDate.value}T${endTime.value}`)
eventData.endDate = endDateTime.toISOString()
}
if (description.value.trim()) {
eventData.description = description.value.trim()
}
if (hasLocation.value) {
eventData.location = {}
if (locationName.value.trim()) {
eventData.location.name = locationName.value.trim()
}
if (locationAddress.value.trim()) {
eventData.location.address = locationAddress.value.trim()
}
if (latitude.value !== undefined && longitude.value !== undefined) {
eventData.location.latitude = latitude.value
eventData.location.longitude = longitude.value
}
}
emit('send', eventData)
// Reset form
resetForm()
isOpen.value = false
} finally {
isSending.value = false
}
}
const resetForm = () => {
eventName.value = ''
startDate.value = ''
startTime.value = ''
hasEndDate.value = false
endDate.value = ''
endTime.value = ''
description.value = ''
hasLocation.value = false
locationName.value = ''
locationAddress.value = ''
latitude.value = undefined
longitude.value = undefined
}
// Reset form when modal opens
watch(isOpen, (open) => {
if (open) {
resetForm()
// Set default start date to today
const today = new Date()
startDate.value = today.toISOString().split('T')[0]
startTime.value = '12:00'
}
})
</script>

View File

@@ -135,6 +135,20 @@
:from-me="message.fromMe"
/>
<!-- Poll -->
<MessagePoll
v-else-if="message.type === 'poll' && message.poll"
:poll="message.poll"
:from-me="message.fromMe"
/>
<!-- Event -->
<MessageEvent
v-else-if="message.type === 'event' && message.event"
:event="message.event"
:from-me="message.fromMe"
/>
<!-- Unknown/unsupported type -->
<div
v-else-if="message.type === 'unknown' || !message.content"
@@ -220,6 +234,8 @@ import MessageDocument from './content/MessageDocument.vue'
import MessageSticker from './content/MessageSticker.vue'
import MessageContact from './content/MessageContact.vue'
import MessageLocation from './content/MessageLocation.vue'
import MessagePoll from './content/MessagePoll.vue'
import MessageEvent from './content/MessageEvent.vue'
import ReactionPicker from './ReactionPicker.vue'
interface Props {

View File

@@ -174,6 +174,24 @@
<p class="text-white font-medium">Suelta los archivos aquí</p>
</div>
</div>
<!-- Contact Send Modal -->
<MessagesContactSendModal
v-model:open="showContactModal"
@send="handleSendContact"
/>
<!-- Poll Send Modal -->
<MessagesPollSendModal
v-model:open="showPollModal"
@send="handleSendPoll"
/>
<!-- Event Send Modal -->
<MessagesEventSendModal
v-model:open="showEventModal"
@send="handleSendEvent"
/>
</div>
</template>
@@ -190,9 +208,37 @@ const props = withDefaults(defineProps<Props>(), {
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<boolean[]>([])
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<string, number> = {
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)
}
</script>

View File

@@ -0,0 +1,201 @@
<template>
<UModal v-model:open="isOpen">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-white">Crear Encuesta</h3>
<UButton
variant="ghost"
icon="i-lucide-x"
size="sm"
@click="isOpen = false"
/>
</div>
</template>
<div class="space-y-4">
<!-- Poll question -->
<div>
<label class="block text-sm font-medium text-[var(--wa-text-muted)] mb-1">
Pregunta
</label>
<UInput
v-model="pollName"
placeholder="¿Cuál es tu opción favorita?"
icon="i-lucide-help-circle"
/>
</div>
<!-- Options -->
<div>
<label class="block text-sm font-medium text-[var(--wa-text-muted)] mb-2">
Opciones (mínimo 2, máximo 12)
</label>
<div class="space-y-2">
<div
v-for="(option, index) in options"
:key="index"
class="flex items-center gap-2"
>
<span class="text-sm text-[var(--wa-text-muted)] w-6">
{{ index + 1 }}.
</span>
<UInput
v-model="options[index]"
:placeholder="`Opción ${index + 1}`"
class="flex-1"
/>
<UButton
v-if="options.length > 2"
variant="ghost"
icon="i-lucide-x"
size="xs"
color="error"
@click="removeOption(index)"
/>
</div>
</div>
<UButton
v-if="options.length < 12"
variant="outline"
icon="i-lucide-plus"
size="sm"
class="mt-2"
@click="addOption"
>
Agregar opción
</UButton>
</div>
<!-- Allow multiple selection -->
<div class="flex items-center justify-between p-3 rounded-lg bg-[var(--wa-bg-light)]">
<div>
<p class="text-sm font-medium text-white">Selección múltiple</p>
<p class="text-xs text-[var(--wa-text-muted)]">
Permitir seleccionar más de una opción
</p>
</div>
<USwitch v-model="allowMultiple" />
</div>
<!-- Max selections (only if multiple) -->
<div v-if="allowMultiple" class="flex items-center gap-3">
<label class="text-sm text-[var(--wa-text-muted)]">
Máximo de selecciones:
</label>
<UInput
v-model.number="maxSelections"
type="number"
:min="1"
:max="validOptions.length"
class="w-20"
/>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton variant="ghost" @click="isOpen = false">
Cancelar
</UButton>
<UButton
:disabled="!isValid"
:loading="isSending"
@click="handleSend"
>
Crear encuesta
</UButton>
</div>
</template>
</UCard>
</template>
</UModal>
</template>
<script setup lang="ts">
interface PollData {
name: string
options: string[]
selectableCount: number
}
const isOpen = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{
send: [poll: PollData]
}>()
const isSending = ref(false)
const pollName = ref('')
const options = ref<string[]>(['', ''])
const allowMultiple = ref(false)
const maxSelections = ref(1)
const validOptions = computed(() =>
options.value.filter(o => o.trim())
)
const isValid = computed(() => {
return pollName.value.trim() && validOptions.value.length >= 2
})
const addOption = () => {
if (options.value.length < 12) {
options.value.push('')
}
}
const removeOption = (index: number) => {
if (options.value.length > 2) {
options.value.splice(index, 1)
}
}
const handleSend = async () => {
if (!isValid.value) return
isSending.value = true
try {
const poll: PollData = {
name: pollName.value.trim(),
options: validOptions.value,
selectableCount: allowMultiple.value
? Math.min(maxSelections.value, validOptions.value.length)
: 1
}
emit('send', poll)
// Reset form
resetForm()
isOpen.value = false
} finally {
isSending.value = false
}
}
const resetForm = () => {
pollName.value = ''
options.value = ['', '']
allowMultiple.value = false
maxSelections.value = 1
}
// Reset form when modal opens
watch(isOpen, (open) => {
if (open) {
resetForm()
}
})
// Adjust max selections when options change
watch(validOptions, (opts) => {
if (maxSelections.value > opts.length) {
maxSelections.value = opts.length || 1
}
})
</script>

View File

@@ -0,0 +1,126 @@
<template>
<div class="min-w-[200px] max-w-[280px]">
<!-- Event header -->
<div class="flex items-center gap-2 mb-2">
<UIcon name="i-lucide-calendar" class="w-5 h-5" :class="iconClass" />
<span class="font-medium" :class="textClass">Evento</span>
</div>
<!-- Event name -->
<p class="font-medium text-lg mb-2" :class="textClass">
{{ event.name }}
</p>
<!-- Date and time -->
<div class="space-y-1 mb-2">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-clock" class="w-4 h-4" :class="mutedTextClass" />
<span class="text-sm" :class="textClass">
{{ formatDate(event.startDate) }}
</span>
</div>
<div v-if="event.endDate" class="flex items-center gap-2">
<UIcon name="i-lucide-arrow-right" class="w-4 h-4" :class="mutedTextClass" />
<span class="text-sm" :class="textClass">
{{ formatDate(event.endDate) }}
</span>
</div>
</div>
<!-- Description -->
<p v-if="event.description" class="text-sm mb-2" :class="mutedTextClass">
{{ event.description }}
</p>
<!-- Location -->
<div
v-if="event.location"
class="flex items-start gap-2 p-2 rounded-lg"
:class="locationBgClass"
>
<UIcon name="i-lucide-map-pin" class="w-4 h-4 mt-0.5" :class="iconClass" />
<div class="flex-1 min-w-0">
<p v-if="event.location.name" class="text-sm font-medium" :class="textClass">
{{ event.location.name }}
</p>
<p v-if="event.location.address" class="text-xs" :class="mutedTextClass">
{{ event.location.address }}
</p>
<a
v-if="event.location.latitude && event.location.longitude"
:href="mapsUrl"
target="_blank"
class="text-xs underline"
:class="linkClass"
>
Ver en mapa
</a>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface EventLocation {
name?: string
address?: string
latitude?: number
longitude?: number
}
interface EventInfo {
name: string
startDate: string
endDate?: string
description?: string
location?: EventLocation
}
interface Props {
event: EventInfo
fromMe?: boolean
}
const props = withDefaults(defineProps<Props>(), {
fromMe: false
})
const formatDate = (dateStr: string): string => {
const date = new Date(dateStr)
return date.toLocaleString('es-AR', {
weekday: 'short',
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const mapsUrl = computed(() => {
if (!props.event.location?.latitude || !props.event.location?.longitude) return ''
const lat = props.event.location.latitude
const lng = props.event.location.longitude
return `https://www.google.com/maps?q=${lat},${lng}`
})
const textClass = computed(() =>
props.fromMe ? 'text-white' : 'text-[var(--wa-text)]'
)
const iconClass = computed(() =>
props.fromMe ? 'text-white/80' : 'text-[var(--wa-green)]'
)
const mutedTextClass = computed(() =>
props.fromMe ? 'text-white/60' : 'text-[var(--wa-text-muted)]'
)
const locationBgClass = computed(() =>
props.fromMe ? 'bg-white/10' : 'bg-[var(--wa-bg-light)]'
)
const linkClass = computed(() =>
props.fromMe ? 'text-white/80 hover:text-white' : 'text-[var(--wa-blue)] hover:text-[var(--wa-green)]'
)
</script>

View File

@@ -0,0 +1,108 @@
<template>
<div class="min-w-[200px] max-w-[280px]">
<!-- Poll header -->
<div class="flex items-center gap-2 mb-2">
<UIcon name="i-lucide-bar-chart-2" class="w-5 h-5" :class="iconClass" />
<span class="font-medium" :class="textClass">Encuesta</span>
</div>
<!-- Poll question -->
<p class="font-medium mb-3" :class="textClass">
{{ poll.name }}
</p>
<!-- Poll options -->
<div class="space-y-2">
<div
v-for="(option, index) in poll.options"
:key="index"
class="relative rounded-lg overflow-hidden"
:class="optionBgClass"
>
<!-- Background bar for vote count -->
<div
v-if="poll.votes && poll.votes[index]"
class="absolute inset-y-0 left-0 transition-all duration-300"
:class="voteBgClass"
:style="{ width: getVotePercentage(index) + '%' }"
/>
<!-- Option content -->
<div class="relative flex items-center justify-between px-3 py-2">
<span class="text-sm" :class="optionTextClass">{{ option }}</span>
<span
v-if="poll.votes && poll.votes[index]"
class="text-xs font-medium"
:class="voteCountClass"
>
{{ poll.votes[index] }}
</span>
</div>
</div>
</div>
<!-- Poll footer -->
<div class="flex items-center justify-between mt-3 text-xs" :class="mutedTextClass">
<span>{{ totalVotes }} voto{{ totalVotes !== 1 ? 's' : '' }}</span>
<span v-if="poll.selectableCount > 1">
Seleccion multiple ({{ poll.selectableCount }})
</span>
</div>
</div>
</template>
<script setup lang="ts">
interface PollInfo {
name: string
options: string[]
votes?: number[]
selectableCount?: number
}
interface Props {
poll: PollInfo
fromMe?: boolean
}
const props = withDefaults(defineProps<Props>(), {
fromMe: false
})
const totalVotes = computed(() => {
if (!props.poll.votes) return 0
return props.poll.votes.reduce((sum, v) => sum + v, 0)
})
const getVotePercentage = (index: number): number => {
if (!props.poll.votes || totalVotes.value === 0) return 0
return Math.round((props.poll.votes[index] / totalVotes.value) * 100)
}
const textClass = computed(() =>
props.fromMe ? 'text-white' : 'text-[var(--wa-text)]'
)
const iconClass = computed(() =>
props.fromMe ? 'text-white/80' : 'text-[var(--wa-green)]'
)
const mutedTextClass = computed(() =>
props.fromMe ? 'text-white/60' : 'text-[var(--wa-text-muted)]'
)
const optionBgClass = computed(() =>
props.fromMe ? 'bg-white/10' : 'bg-[var(--wa-bg-light)]'
)
const optionTextClass = computed(() =>
props.fromMe ? 'text-white' : 'text-[var(--wa-text)]'
)
const voteBgClass = computed(() =>
props.fromMe ? 'bg-white/20' : 'bg-[var(--wa-green)]/20'
)
const voteCountClass = computed(() =>
props.fromMe ? 'text-white/70' : 'text-[var(--wa-text-muted)]'
)
</script>

View File

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

View File

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

View File

@@ -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<TextMessageBody>(event)
const messageText = body.content || body.message
const body = await readBody<JsonMessageBody>(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
*/