Fix: Usar URL interna para debug webhook receiver (bypass authentik)
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m4s

This commit is contained in:
2025-12-02 21:27:45 -06:00
parent 80d0042c7e
commit 8f44826e64
14 changed files with 642 additions and 21 deletions

View File

@@ -27,10 +27,13 @@
</div>
</div>
<div class="flex items-center justify-between">
<p class="text-sm text-[var(--wa-text-muted)] truncate">{{ chat.lastMessage }}</p>
<div class="flex items-center gap-1 text-sm text-[var(--wa-text-muted)] truncate">
<UIcon v-if="messageTypeIcon" :name="messageTypeIcon" class="w-4 h-4 flex-shrink-0" />
<span class="truncate">{{ lastMessagePreview }}</span>
</div>
<span
v-if="chat.unreadCount > 0"
class="bg-[var(--wa-green-light)] text-white text-xs rounded-full px-2 py-0.5"
class="bg-[var(--wa-green-light)] text-white text-xs rounded-full px-2 py-0.5 flex-shrink-0"
>
{{ chat.unreadCount }}
</span>
@@ -66,7 +69,9 @@ interface Chat {
profilePicture?: string
lastMessage: string
lastMessageAt: Date
lastMessageType?: string
unreadCount: number
isGroup?: boolean
}
interface Props {
@@ -74,13 +79,57 @@ interface Props {
active?: boolean
}
defineProps<Props>()
const props = defineProps<Props>()
defineEmits<{
click: []
}>()
const showDebug = ref(false)
// Message type to icon mapping
const messageTypeIcons: Record<string, string> = {
image: 'i-lucide-image',
video: 'i-lucide-video',
audio: 'i-lucide-music',
document: 'i-lucide-file',
sticker: 'i-lucide-sticker',
contact: 'i-lucide-contact',
location: 'i-lucide-map-pin'
}
// Message type to placeholder text
const messageTypePlaceholders: Record<string, string> = {
image: 'Foto',
video: 'Video',
audio: 'Audio',
document: 'Documento',
sticker: 'Sticker',
contact: 'Contacto',
location: 'Ubicación'
}
const messageTypeIcon = computed(() => {
const type = props.chat.lastMessageType
if (!type || type === 'text') return null
return messageTypeIcons[type] || null
})
const lastMessagePreview = computed(() => {
const type = props.chat.lastMessageType
// If there's text content, show it
if (props.chat.lastMessage) {
return props.chat.lastMessage
}
// Otherwise show placeholder based on type
if (type && messageTypePlaceholders[type]) {
return messageTypePlaceholders[type]
}
return ''
})
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)

View File

@@ -154,14 +154,22 @@
>
<UIcon name="i-lucide-reply" class="w-4 h-4" />
</button>
<button
class="p-1 rounded-full hover:bg-black/10"
:class="message.fromMe ? 'text-white/60 hover:text-white' : 'text-[var(--wa-text-muted)] hover:text-[var(--wa-text)]'"
title="Reaccionar"
@click="$emit('react', message)"
>
<UIcon name="i-lucide-smile-plus" class="w-4 h-4" />
</button>
<div class="relative">
<button
class="p-1 rounded-full hover:bg-black/10"
:class="message.fromMe ? 'text-white/60 hover:text-white' : 'text-[var(--wa-text-muted)] hover:text-[var(--wa-text)]'"
title="Reaccionar"
@click.stop="showReactionPicker = !showReactionPicker"
>
<UIcon name="i-lucide-smile-plus" class="w-4 h-4" />
</button>
<ReactionPicker
:visible="showReactionPicker"
position="bottom"
@select="handleReaction"
@close="showReactionPicker = false"
/>
</div>
<button
class="p-1 rounded-full hover:bg-black/10"
:class="message.fromMe ? 'text-white/60 hover:text-white' : 'text-[var(--wa-text-muted)] hover:text-[var(--wa-text)]'"
@@ -206,6 +214,7 @@ 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 ReactionPicker from './ReactionPicker.vue'
interface Props {
message: Message
@@ -217,13 +226,20 @@ const props = withDefaults(defineProps<Props>(), {
isGroup: false
})
defineEmits<{
const emit = defineEmits<{
reply: [message: Message]
react: [message: Message]
react: [message: Message, emoji: string]
scrollToMessage: [id: string]
}>()
const showDebug = ref(false)
const showReactionPicker = ref(false)
// Handle reaction selection
const handleReaction = (emoji: string) => {
emit('react', props.message, emoji)
showReactionPicker.value = false
}
// Computed properties
const senderName = computed(() => {

View File

@@ -117,6 +117,7 @@
:maxrows="5"
class="bg-[var(--wa-bg-light)]"
@keydown.enter.exact.prevent="handleSend"
@input="emit('typing')"
/>
</div>
@@ -165,6 +166,8 @@ const emit = defineEmits<{
send: [content: string, files: File[], caption: string, quotedId?: string]
sendVoice: [audioFile: File]
cancelReply: []
typing: []
recording: [isRecording: boolean]
}>()
// Refs for file inputs
@@ -266,18 +269,21 @@ const handleSend = () => {
const startRecording = async () => {
const success = await startAudioRecording()
if (!success) {
// Show error toast
if (success) {
emit('recording', true)
} else {
console.error('Failed to start recording')
}
}
const cancelRecording = () => {
cancelAudioRecording()
emit('recording', false)
}
const sendVoiceMessage = () => {
stopRecording()
emit('recording', false)
// Wait a bit for the blob to be ready
setTimeout(() => {

View File

@@ -0,0 +1,125 @@
<template>
<div
v-if="visible"
class="absolute z-50 bg-[var(--wa-bg-dark)] rounded-full shadow-lg border border-[var(--wa-border)] p-1 flex items-center gap-1"
:class="position === 'top' ? 'bottom-full mb-2' : 'top-full mt-2'"
@click.stop
>
<button
v-for="emoji in quickReactions"
:key="emoji"
class="w-8 h-8 flex items-center justify-center text-lg hover:bg-[var(--wa-bg-light)] rounded-full transition-transform hover:scale-125"
@click="selectReaction(emoji)"
>
{{ emoji }}
</button>
<button
class="w-8 h-8 flex items-center justify-center text-lg hover:bg-[var(--wa-bg-light)] rounded-full"
@click="showFullPicker = true"
title="Más emojis"
>
<UIcon name="i-lucide-plus" class="w-4 h-4 text-[var(--wa-text-muted)]" />
</button>
</div>
<!-- Full emoji picker modal -->
<UModal v-model="showFullPicker">
<div class="p-4">
<h3 class="text-lg font-semibold mb-4 text-[var(--wa-text)]">Elegir reacción</h3>
<div class="grid grid-cols-8 gap-2 max-h-64 overflow-y-auto">
<button
v-for="emoji in allEmojis"
:key="emoji"
class="w-10 h-10 flex items-center justify-center text-2xl hover:bg-[var(--wa-bg-light)] rounded transition-transform hover:scale-110"
@click="selectReaction(emoji); showFullPicker = false"
>
{{ emoji }}
</button>
</div>
<div class="mt-4 flex justify-end">
<UButton variant="ghost" @click="showFullPicker = false">
Cancelar
</UButton>
</div>
</div>
</UModal>
</template>
<script setup lang="ts">
interface Props {
visible: boolean
position?: 'top' | 'bottom'
}
withDefaults(defineProps<Props>(), {
visible: false,
position: 'top'
})
const emit = defineEmits<{
select: [emoji: string]
close: []
}>()
const showFullPicker = ref(false)
// Quick reactions (WhatsApp style)
const quickReactions = ['👍', '❤️', '😂', '😮', '😢', '🙏']
// Extended emoji list
const allEmojis = [
// Smileys
'😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂',
'🙂', '🙃', '😉', '😊', '😇', '🥰', '😍', '🤩',
'😘', '😗', '😚', '😙', '🥲', '😋', '😛', '😜',
'🤪', '😝', '🤑', '🤗', '🤭', '🤫', '🤔', '🤐',
'🤨', '😐', '😑', '😶', '😏', '😒', '🙄', '😬',
'😮‍💨', '🤥', '😌', '😔', '😪', '🤤', '😴', '😷',
// Gestures
'👍', '👎', '👌', '🤌', '🤏', '✌️', '🤞', '🤟',
'🤘', '🤙', '👈', '👉', '👆', '👇', '☝️', '👋',
'🤚', '🖐️', '✋', '🖖', '👏', '🙌', '👐', '🤲',
'🤝', '🙏', '✍️', '💪', '🦾', '🦿', '🦵', '🦶',
// Hearts
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍',
'🤎', '💔', '❣️', '💕', '💞', '💓', '💗', '💖',
'💘', '💝', '💟', '♥️', '💌', '💋', '👄', '👅',
// Objects
'🎉', '🎊', '🎈', '🎁', '🏆', '🥇', '🥈', '🥉',
'⚽', '🏀', '🏈', '⚾', '🎾', '🏐', '🎱', '🎮',
'🎵', '🎶', '🎤', '🎧', '🎸', '🎹', '🎺', '🎻',
'🍕', '🍔', '🍟', '🌭', '🍿', '🧁', '🍰', '🎂',
'☕', '🍵', '🍺', '🍻', '🥂', '🍷', '🥃', '🍸',
// Nature
'🌸', '🌺', '🌹', '🌷', '🌻', '🌼', '💐', '🌿',
'☀️', '🌙', '⭐', '🌟', '✨', '💫', '🔥', '💧',
'🌈', '☁️', '⛈️', '❄️', '☃️', '⚡', '🌊', '🌍',
// Animals
'🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼',
'🐨', '🐯', '🦁', '🐮', '🐷', '🐸', '🐵', '🐔',
'🦄', '🐝', '🦋', '🐌', '🐞', '🐜', '🦗', '🕷️'
]
const selectReaction = (emoji: string) => {
emit('select', emoji)
emit('close')
}
// Close on click outside
onMounted(() => {
const handleClickOutside = () => {
emit('close')
}
// Delay to avoid immediate close
setTimeout(() => {
document.addEventListener('click', handleClickOutside)
}, 100)
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
})
</script>

View File

@@ -0,0 +1,166 @@
/**
* Composable for managing presence (typing indicators, online status)
*/
export type PresenceState = 'available' | 'unavailable' | 'composing' | 'recording' | 'paused'
interface PresenceInfo {
presence: PresenceState | null
lastSeen: Date | null
}
interface PresenceStore {
[jid: string]: PresenceInfo
}
export function usePresence(instanceId: Ref<string | null>) {
const { on } = useRealtime()
// Store of presence states by JID
const presences = ref<PresenceStore>({})
// Debounce timer for composing presence
let composingTimeout: NodeJS.Timeout | null = null
// Subscribe to presence updates for a contact
const subscribeToPresence = async (jid: string): Promise<void> => {
if (!instanceId.value) return
try {
await $fetch(`/api/presence/${instanceId.value}/subscribe`, {
method: 'POST',
body: { jid }
})
} catch (error) {
console.error('[usePresence] Error subscribing:', error)
}
}
// Send own presence update
const sendPresence = async (jid: string, presence: PresenceState): Promise<void> => {
if (!instanceId.value) return
try {
await $fetch(`/api/presence/${instanceId.value}/send`, {
method: 'POST',
body: { jid, presence }
})
} catch (error) {
console.error('[usePresence] Error sending presence:', error)
}
}
// Send composing presence with automatic paused after timeout
const sendTyping = async (jid: string): Promise<void> => {
if (!instanceId.value) return
// Clear previous timeout
if (composingTimeout) {
clearTimeout(composingTimeout)
}
// Send composing
await sendPresence(jid, 'composing')
// Auto-pause after 5 seconds of no typing
composingTimeout = setTimeout(async () => {
await sendPresence(jid, 'paused')
}, 5000)
}
// Send recording presence
const sendRecording = async (jid: string): Promise<void> => {
await sendPresence(jid, 'recording')
}
// Stop typing/recording indicator
const stopIndicator = async (jid: string): Promise<void> => {
if (composingTimeout) {
clearTimeout(composingTimeout)
composingTimeout = null
}
await sendPresence(jid, 'paused')
}
// Get presence for a JID
const getPresence = (jid: string): PresenceInfo => {
return presences.value[jid] || { presence: null, lastSeen: null }
}
// Get presence text for display
const getPresenceText = (jid: string): string | null => {
const presence = getPresence(jid)
if (!presence.presence) return null
switch (presence.presence) {
case 'composing':
return 'escribiendo...'
case 'recording':
return 'grabando audio...'
case 'available':
return 'en línea'
case 'unavailable':
if (presence.lastSeen) {
return `última vez ${formatLastSeen(presence.lastSeen)}`
}
return null
case 'paused':
return null
default:
return null
}
}
// Format last seen date
const formatLastSeen = (date: Date): string => {
const now = new Date()
const diff = now.getTime() - date.getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return 'hace un momento'
if (minutes < 60) return `hace ${minutes} min`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `hace ${hours}h`
const days = Math.floor(hours / 24)
if (days === 1) return 'ayer'
if (days < 7) return `hace ${days} días`
return date.toLocaleDateString('es', { day: 'numeric', month: 'short' })
}
// Listen for presence updates from SSE
onMounted(() => {
on('presence.update', (data: any) => {
if (data.instanceId !== instanceId.value) return
// Update presence store
for (const [participantJid, presence] of Object.entries(data.presences)) {
const presenceData = presence as { lastKnownPresence: string; lastSeen?: number }
presences.value[participantJid] = {
presence: presenceData.lastKnownPresence as PresenceState,
lastSeen: presenceData.lastSeen ? new Date(presenceData.lastSeen * 1000) : null
}
}
})
})
// Cleanup on unmount
onUnmounted(() => {
if (composingTimeout) {
clearTimeout(composingTimeout)
}
})
return {
presences: readonly(presences),
subscribeToPresence,
sendPresence,
sendTyping,
sendRecording,
stopIndicator,
getPresence,
getPresenceText
}
}

View File

@@ -131,7 +131,10 @@
<UAvatar :alt="selectedChat.name" size="md" />
<div class="flex-1">
<p class="font-medium text-[var(--wa-text)]">{{ selectedChat.name }}</p>
<p class="text-sm text-[var(--wa-text-muted)]">{{ selectedChat.jid }}</p>
<p v-if="currentChatPresence" class="text-sm text-[var(--wa-green-light)]">
{{ currentChatPresence }}
</p>
<p v-else class="text-sm text-[var(--wa-text-muted)]">{{ selectedChat.jid }}</p>
</div>
<button
@click="showSelectedChatDebug = !showSelectedChatDebug"
@@ -180,6 +183,8 @@
@send="handleSendMessage"
@send-voice="handleSendVoice"
@cancel-reply="replyingTo = null"
@typing="handleTyping"
@recording="handleRecordingPresence"
/>
</div>
</template>
@@ -199,6 +204,16 @@ const { instances, fetchInstances } = useInstances()
const { isConnected, lastEvent, on } = useRealtime()
const selectedInstance = ref<{ label: string; value: string } | null>(null)
// Presence composable
const instanceIdRef = computed(() => selectedInstance.value?.value || null)
const {
subscribeToPresence,
sendTyping,
sendRecording,
stopIndicator,
getPresenceText
} = usePresence(instanceIdRef)
const searchQuery = ref('')
const selectedChat = ref<any>(null)
const chats = ref<any[]>([])
@@ -251,6 +266,11 @@ watch(selectedChat, async (chat) => {
loadingMessages.value = true
try {
messages.value = await $fetch(`/api/messages/${selectedInstance.value.value}/${chat.id}`)
// Subscribe to presence updates for this chat (if not a group)
if (!chat.isGroup && chat.jid) {
await subscribeToPresence(chat.jid)
}
} catch (e) {
console.error('Error loading messages:', e)
messages.value = []
@@ -259,6 +279,12 @@ watch(selectedChat, async (chat) => {
}
})
// Computed presence text for current chat
const currentChatPresence = computed(() => {
if (!selectedChat.value?.jid) return null
return getPresenceText(selectedChat.value.jid)
})
const filteredChats = computed(() => {
if (!searchQuery.value) return chats.value
return chats.value.filter(chat =>
@@ -382,9 +408,37 @@ const handleReply = (message: any) => {
}
// Handle react action
const handleReact = (message: any) => {
// TODO: Show emoji picker
console.log('React to:', message)
const handleReact = async (message: any, emoji: string) => {
if (!selectedInstance.value?.value) return
try {
await $fetch(`/api/messages/${selectedInstance.value.value}/react`, {
method: 'POST',
body: {
messageId: message.messageId,
emoji
}
})
} catch (e) {
console.error('Error sending reaction:', e)
}
}
// Handle typing event from input
const handleTyping = () => {
if (!selectedChat.value?.jid || selectedChat.value?.isGroup) return
sendTyping(selectedChat.value.jid)
}
// Handle recording presence
const handleRecordingPresence = (isRecording: boolean) => {
if (!selectedChat.value?.jid || selectedChat.value?.isGroup) return
if (isRecording) {
sendRecording(selectedChat.value.jid)
} else {
stopIndicator(selectedChat.value.jid)
}
}
// Reload chats for current instance