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
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:
150
app/components/messages/ContactSendModal.vue
Normal file
150
app/components/messages/ContactSendModal.vue
Normal 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>
|
||||
257
app/components/messages/EventSendModal.vue
Normal file
257
app/components/messages/EventSendModal.vue
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
201
app/components/messages/PollSendModal.vue
Normal file
201
app/components/messages/PollSendModal.vue
Normal 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>
|
||||
126
app/components/messages/content/MessageEvent.vue
Normal file
126
app/components/messages/content/MessageEvent.vue
Normal 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>
|
||||
108
app/components/messages/content/MessagePoll.vue
Normal file
108
app/components/messages/content/MessagePoll.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user