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> </div>
<div class="flex items-center justify-between"> <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 <span
v-if="chat.unreadCount > 0" 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 }} {{ chat.unreadCount }}
</span> </span>
@@ -66,7 +69,9 @@ interface Chat {
profilePicture?: string profilePicture?: string
lastMessage: string lastMessage: string
lastMessageAt: Date lastMessageAt: Date
lastMessageType?: string
unreadCount: number unreadCount: number
isGroup?: boolean
} }
interface Props { interface Props {
@@ -74,13 +79,57 @@ interface Props {
active?: boolean active?: boolean
} }
defineProps<Props>() const props = defineProps<Props>()
defineEmits<{ defineEmits<{
click: [] click: []
}>() }>()
const showDebug = ref(false) 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) => { const copyToClipboard = async (text: string) => {
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)

View File

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

View File

@@ -117,6 +117,7 @@
:maxrows="5" :maxrows="5"
class="bg-[var(--wa-bg-light)]" class="bg-[var(--wa-bg-light)]"
@keydown.enter.exact.prevent="handleSend" @keydown.enter.exact.prevent="handleSend"
@input="emit('typing')"
/> />
</div> </div>
@@ -165,6 +166,8 @@ const emit = defineEmits<{
send: [content: string, files: File[], caption: string, quotedId?: string] send: [content: string, files: File[], caption: string, quotedId?: string]
sendVoice: [audioFile: File] sendVoice: [audioFile: File]
cancelReply: [] cancelReply: []
typing: []
recording: [isRecording: boolean]
}>() }>()
// Refs for file inputs // Refs for file inputs
@@ -266,18 +269,21 @@ const handleSend = () => {
const startRecording = async () => { const startRecording = async () => {
const success = await startAudioRecording() const success = await startAudioRecording()
if (!success) { if (success) {
// Show error toast emit('recording', true)
} else {
console.error('Failed to start recording') console.error('Failed to start recording')
} }
} }
const cancelRecording = () => { const cancelRecording = () => {
cancelAudioRecording() cancelAudioRecording()
emit('recording', false)
} }
const sendVoiceMessage = () => { const sendVoiceMessage = () => {
stopRecording() stopRecording()
emit('recording', false)
// Wait a bit for the blob to be ready // Wait a bit for the blob to be ready
setTimeout(() => { 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" /> <UAvatar :alt="selectedChat.name" size="md" />
<div class="flex-1"> <div class="flex-1">
<p class="font-medium text-[var(--wa-text)]">{{ selectedChat.name }}</p> <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> </div>
<button <button
@click="showSelectedChatDebug = !showSelectedChatDebug" @click="showSelectedChatDebug = !showSelectedChatDebug"
@@ -180,6 +183,8 @@
@send="handleSendMessage" @send="handleSendMessage"
@send-voice="handleSendVoice" @send-voice="handleSendVoice"
@cancel-reply="replyingTo = null" @cancel-reply="replyingTo = null"
@typing="handleTyping"
@recording="handleRecordingPresence"
/> />
</div> </div>
</template> </template>
@@ -199,6 +204,16 @@ const { instances, fetchInstances } = useInstances()
const { isConnected, lastEvent, on } = useRealtime() const { isConnected, lastEvent, on } = useRealtime()
const selectedInstance = ref<{ label: string; value: string } | null>(null) 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 searchQuery = ref('')
const selectedChat = ref<any>(null) const selectedChat = ref<any>(null)
const chats = ref<any[]>([]) const chats = ref<any[]>([])
@@ -251,6 +266,11 @@ watch(selectedChat, async (chat) => {
loadingMessages.value = true loadingMessages.value = true
try { try {
messages.value = await $fetch(`/api/messages/${selectedInstance.value.value}/${chat.id}`) 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) { } catch (e) {
console.error('Error loading messages:', e) console.error('Error loading messages:', e)
messages.value = [] 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(() => { const filteredChats = computed(() => {
if (!searchQuery.value) return chats.value if (!searchQuery.value) return chats.value
return chats.value.filter(chat => return chats.value.filter(chat =>
@@ -382,9 +408,37 @@ const handleReply = (message: any) => {
} }
// Handle react action // Handle react action
const handleReact = (message: any) => { const handleReact = async (message: any, emoji: string) => {
// TODO: Show emoji picker if (!selectedInstance.value?.value) return
console.log('React to:', message)
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 // Reload chats for current instance

View File

@@ -40,6 +40,12 @@ export default defineEventHandler(async (event) => {
}, },
'message.status': (data: any) => { 'message.status': (data: any) => {
res.write(`event: message.status\ndata: ${JSON.stringify(data)}\n\n`) res.write(`event: message.status\ndata: ${JSON.stringify(data)}\n\n`)
},
'message.reaction': (data: any) => {
res.write(`event: message.reaction\ndata: ${JSON.stringify(data)}\n\n`)
},
'presence.update': (data: any) => {
res.write(`event: presence.update\ndata: ${JSON.stringify(data)}\n\n`)
} }
} }

View File

@@ -12,6 +12,7 @@ interface ChatRow {
unread_count: number unread_count: number
last_message_at: Date | null last_message_at: Date | null
last_message: string | null last_message: string | null
last_message_type: string | null
} }
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {

View File

@@ -0,0 +1,62 @@
/**
* POST /api/messages/:instanceId/react
* Send a reaction to a message
*/
import { baileysManager } from '../../../services/baileys/manager'
import { query } from '../../../utils/database'
export default defineEventHandler(async (event) => {
const username = getHeader(event, 'x-authentik-username')
if (!username) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
const instanceId = getRouterParam(event, 'instanceId')
if (!instanceId) {
throw createError({ statusCode: 400, message: 'Missing instanceId' })
}
const body = await readBody<{ messageId: string; emoji: string }>(event)
if (!body?.messageId) {
throw createError({ statusCode: 400, message: 'Missing messageId in request body' })
}
// emoji can be empty string to remove reaction
if (body.emoji === undefined) {
throw createError({ statusCode: 400, message: 'Missing emoji in request body' })
}
try {
// Get the message to find its JID
const msgResult = await query<{ raw_message: any }>(
`SELECT raw_message FROM messages WHERE instance_id = $1 AND message_id = $2`,
[instanceId, body.messageId]
)
if (msgResult.rows.length === 0) {
throw createError({ statusCode: 404, message: 'Message not found' })
}
const rawMessage = msgResult.rows[0].raw_message
const jid = rawMessage?.key?.remoteJid
if (!jid) {
throw createError({ statusCode: 400, message: 'Could not determine message JID' })
}
await baileysManager.sendReaction(instanceId, jid, body.messageId, body.emoji)
return { success: true, messageId: body.messageId, emoji: body.emoji }
} catch (error: any) {
console.error('[React API] Error sending reaction:', error)
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
message: error.message || 'Error sending reaction'
})
}
})

View File

@@ -0,0 +1,42 @@
/**
* GET /api/presence/:instanceId/:jid
* Get cached presence for a contact
*/
import { query } from '../../../utils/database'
export default defineEventHandler(async (event) => {
const username = getHeader(event, 'x-authentik-username')
if (!username) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
const instanceId = getRouterParam(event, 'instanceId')
const jid = getRouterParam(event, 'jid')
if (!instanceId || !jid) {
throw createError({ statusCode: 400, message: 'Missing instanceId or jid' })
}
try {
const result = await query<{ presence: string; last_seen: Date }>(
`SELECT presence, last_seen FROM presence_cache
WHERE instance_id = $1 AND jid = $2`,
[instanceId, jid]
)
if (result.rows.length === 0) {
return { presence: null, lastSeen: null }
}
return {
presence: result.rows[0].presence,
lastSeen: result.rows[0].last_seen
}
} catch (error: any) {
console.error('[Presence API] Error getting presence:', error)
throw createError({
statusCode: 500,
message: 'Error getting presence'
})
}
})

View File

@@ -0,0 +1,44 @@
/**
* POST /api/presence/:instanceId/send
* Send presence update (composing, recording, available, unavailable, paused)
*/
import { baileysManager } from '../../../services/baileys/manager'
type PresenceType = 'composing' | 'recording' | 'available' | 'unavailable' | 'paused'
const VALID_PRESENCES: PresenceType[] = ['composing', 'recording', 'available', 'unavailable', 'paused']
export default defineEventHandler(async (event) => {
const username = getHeader(event, 'x-authentik-username')
if (!username) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
const instanceId = getRouterParam(event, 'instanceId')
if (!instanceId) {
throw createError({ statusCode: 400, message: 'Missing instanceId' })
}
const body = await readBody<{ jid: string; presence: PresenceType }>(event)
if (!body?.jid) {
throw createError({ statusCode: 400, message: 'Missing jid in request body' })
}
if (!body?.presence || !VALID_PRESENCES.includes(body.presence)) {
throw createError({
statusCode: 400,
message: `Invalid presence. Must be one of: ${VALID_PRESENCES.join(', ')}`
})
}
try {
await baileysManager.sendPresence(instanceId, body.jid, body.presence)
return { success: true, jid: body.jid, presence: body.presence }
} catch (error: any) {
console.error('[Presence API] Error sending presence:', error)
throw createError({
statusCode: 500,
message: error.message || 'Error sending presence'
})
}
})

View File

@@ -0,0 +1,33 @@
/**
* POST /api/presence/:instanceId/subscribe
* Subscribe to presence updates for a contact
*/
import { baileysManager } from '../../../services/baileys/manager'
export default defineEventHandler(async (event) => {
const username = getHeader(event, 'x-authentik-username')
if (!username) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
const instanceId = getRouterParam(event, 'instanceId')
if (!instanceId) {
throw createError({ statusCode: 400, message: 'Missing instanceId' })
}
const body = await readBody<{ jid: string }>(event)
if (!body?.jid) {
throw createError({ statusCode: 400, message: 'Missing jid in request body' })
}
try {
await baileysManager.subscribeToPresence(instanceId, body.jid)
return { success: true, jid: body.jid }
} catch (error: any) {
console.error('[Presence API] Error subscribing:', error)
throw createError({
statusCode: 500,
message: error.message || 'Error subscribing to presence'
})
}
})

View File

@@ -58,7 +58,16 @@ export default defineEventHandler(async (event) => {
const controller = new AbortController() const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 10000) const timeout = setTimeout(() => controller.abort(), 10000)
const response = await fetch(webhook.url, { // Check if the URL is pointing to our own debug receiver
// If so, use internal URL to bypass authentik
let targetUrl = webhook.url
if (webhook.url.includes('/api/debug/webhook-receiver')) {
// Use internal URL (works in Docker/local environments)
const internalPort = process.env.PORT || 3000
targetUrl = `http://localhost:${internalPort}/api/debug/webhook-receiver`
}
const response = await fetch(targetUrl, {
method: 'POST', method: 'POST',
headers, headers,
body, body,

View File

@@ -123,11 +123,19 @@ class WebhookDispatcher {
headers['X-Webhook-Signature'] = `sha256=${signature}` headers['X-Webhook-Signature'] = `sha256=${signature}`
} }
// Check if the URL is pointing to our own debug receiver
// If so, use internal URL to bypass authentik
let targetUrl = webhook.url
if (webhook.url.includes('/api/debug/webhook-receiver')) {
const internalPort = process.env.PORT || 3000
targetUrl = `http://localhost:${internalPort}/api/debug/webhook-receiver`
}
const controller = new AbortController() const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), webhook.timeout_ms) const timeout = setTimeout(() => controller.abort(), webhook.timeout_ms)
try { try {
const response = await fetch(webhook.url, { const response = await fetch(targetUrl, {
method: 'POST', method: 'POST',
headers, headers,
body, body,