Feature: Agregar botón para crear webhook de debug automáticamente
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m4s
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m4s
- Agregar botón "Crear Webhook de Debug" en WebhookReceiverSection - Detectar si ya existe un webhook apuntando al receptor de debug - Permitir eliminar el webhook de debug - Incluir todos los eventos disponibles al crear el webhook - También incluye mejoras previas de manejo de media y mensajes
This commit is contained in:
78
server/api/media/[instanceId]/[messageId].get.ts
Normal file
78
server/api/media/[instanceId]/[messageId].get.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* GET /api/media/:instanceId/:messageId
|
||||
* Download and serve media for a message
|
||||
*
|
||||
* This endpoint:
|
||||
* 1. Checks if media is already cached locally
|
||||
* 2. If not, downloads from WhatsApp using Baileys
|
||||
* 3. Caches the file locally
|
||||
* 4. Streams the file to the client
|
||||
*/
|
||||
import { createReadStream } from 'fs'
|
||||
import { stat } from 'fs/promises'
|
||||
import { downloadAndCacheMedia, getCachedMediaPath } from '../../../services/media/downloader'
|
||||
import { sendStream } from 'h3'
|
||||
|
||||
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 messageId = getRouterParam(event, 'messageId')
|
||||
|
||||
if (!instanceId || !messageId) {
|
||||
throw createError({ statusCode: 400, message: 'Missing instanceId or messageId' })
|
||||
}
|
||||
|
||||
try {
|
||||
// First check if already cached
|
||||
let cached = await getCachedMediaPath(instanceId, messageId)
|
||||
|
||||
// If not cached, download and cache
|
||||
if (!cached) {
|
||||
console.log(`[Media API] Downloading media for: ${messageId}`)
|
||||
const result = await downloadAndCacheMedia(instanceId, messageId)
|
||||
|
||||
if (!result) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: 'Media not found or could not be downloaded'
|
||||
})
|
||||
}
|
||||
|
||||
cached = { path: result.path, mimetype: result.mimetype }
|
||||
}
|
||||
|
||||
// Get file stats for Content-Length
|
||||
const stats = await stat(cached.path)
|
||||
|
||||
// Set response headers
|
||||
setHeader(event, 'Content-Type', cached.mimetype)
|
||||
setHeader(event, 'Content-Length', stats.size.toString())
|
||||
setHeader(event, 'Cache-Control', 'public, max-age=86400') // Cache for 24 hours
|
||||
|
||||
// For documents, suggest download with filename
|
||||
const query = getQuery(event)
|
||||
if (query.download === 'true' || cached.mimetype.startsWith('application/')) {
|
||||
const filename = (query.filename as string) || `media-${messageId}`
|
||||
setHeader(event, 'Content-Disposition', `attachment; filename="${filename}"`)
|
||||
}
|
||||
|
||||
// Stream the file
|
||||
const stream = createReadStream(cached.path)
|
||||
return sendStream(event, stream)
|
||||
} catch (error: any) {
|
||||
console.error(`[Media API] Error serving media ${messageId}:`, error)
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: 'Error retrieving media'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* GET /api/messages/:instanceId/:chatId
|
||||
* Get messages for a chat
|
||||
* Get messages for a chat with full parsed data from raw_message
|
||||
*/
|
||||
import { query } from '../../../../utils/database'
|
||||
|
||||
@@ -15,6 +15,201 @@ interface MessageRow {
|
||||
media_url: string | null
|
||||
timestamp: Date
|
||||
status: string
|
||||
raw_message: any
|
||||
participant_jid: string | null
|
||||
push_name: string | null
|
||||
quoted_message_id: string | null
|
||||
}
|
||||
|
||||
interface ChatRow {
|
||||
is_group: boolean
|
||||
}
|
||||
|
||||
// Parse different message types from raw Baileys message
|
||||
function parseRawMessage(raw: any, messageType: string) {
|
||||
if (!raw?.message) return {}
|
||||
|
||||
const msg = raw.message
|
||||
const result: any = {}
|
||||
|
||||
// Extract media info based on type
|
||||
if (messageType === 'image' && msg.imageMessage) {
|
||||
result.media = {
|
||||
mimetype: msg.imageMessage.mimetype,
|
||||
filesize: msg.imageMessage.fileLength ? Number(msg.imageMessage.fileLength) : undefined,
|
||||
width: msg.imageMessage.width,
|
||||
height: msg.imageMessage.height,
|
||||
thumbnail: msg.imageMessage.jpegThumbnail
|
||||
? Buffer.from(msg.imageMessage.jpegThumbnail).toString('base64')
|
||||
: undefined,
|
||||
isViewOnce: !!msg.imageMessage.viewOnce
|
||||
}
|
||||
result.caption = msg.imageMessage.caption
|
||||
}
|
||||
|
||||
if (messageType === 'video' && msg.videoMessage) {
|
||||
result.media = {
|
||||
mimetype: msg.videoMessage.mimetype,
|
||||
filesize: msg.videoMessage.fileLength ? Number(msg.videoMessage.fileLength) : undefined,
|
||||
width: msg.videoMessage.width,
|
||||
height: msg.videoMessage.height,
|
||||
duration: msg.videoMessage.seconds,
|
||||
thumbnail: msg.videoMessage.jpegThumbnail
|
||||
? Buffer.from(msg.videoMessage.jpegThumbnail).toString('base64')
|
||||
: undefined,
|
||||
isViewOnce: !!msg.videoMessage.viewOnce
|
||||
}
|
||||
result.caption = msg.videoMessage.caption
|
||||
}
|
||||
|
||||
if (messageType === 'audio' && msg.audioMessage) {
|
||||
result.media = {
|
||||
mimetype: msg.audioMessage.mimetype,
|
||||
filesize: msg.audioMessage.fileLength ? Number(msg.audioMessage.fileLength) : undefined,
|
||||
duration: msg.audioMessage.seconds,
|
||||
isPtt: !!msg.audioMessage.ptt,
|
||||
waveform: msg.audioMessage.waveform
|
||||
? Array.from(msg.audioMessage.waveform)
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (messageType === 'document' && msg.documentMessage) {
|
||||
result.media = {
|
||||
mimetype: msg.documentMessage.mimetype,
|
||||
filename: msg.documentMessage.fileName,
|
||||
filesize: msg.documentMessage.fileLength ? Number(msg.documentMessage.fileLength) : undefined,
|
||||
thumbnail: msg.documentMessage.jpegThumbnail
|
||||
? Buffer.from(msg.documentMessage.jpegThumbnail).toString('base64')
|
||||
: undefined
|
||||
}
|
||||
result.caption = msg.documentMessage.caption
|
||||
}
|
||||
|
||||
if (messageType === 'sticker' && msg.stickerMessage) {
|
||||
result.media = {
|
||||
mimetype: msg.stickerMessage.mimetype,
|
||||
width: msg.stickerMessage.width,
|
||||
height: msg.stickerMessage.height,
|
||||
filesize: msg.stickerMessage.fileLength ? Number(msg.stickerMessage.fileLength) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (messageType === 'contact' && msg.contactMessage) {
|
||||
const vcard = msg.contactMessage.vcard || ''
|
||||
const phones = extractPhonesFromVCard(vcard)
|
||||
|
||||
result.contact = {
|
||||
displayName: msg.contactMessage.displayName,
|
||||
vcard: vcard,
|
||||
phones: phones
|
||||
}
|
||||
}
|
||||
|
||||
if (messageType === 'location' && msg.locationMessage) {
|
||||
result.location = {
|
||||
latitude: msg.locationMessage.degreesLatitude,
|
||||
longitude: msg.locationMessage.degreesLongitude,
|
||||
name: msg.locationMessage.name,
|
||||
address: msg.locationMessage.address,
|
||||
url: msg.locationMessage.url
|
||||
}
|
||||
}
|
||||
|
||||
// Extract quoted message if exists
|
||||
const contextInfo = getContextInfo(msg)
|
||||
if (contextInfo?.quotedMessage) {
|
||||
const quotedType = getMessageType(contextInfo.quotedMessage)
|
||||
result.quoted = {
|
||||
id: contextInfo.stanzaId,
|
||||
content: extractTextContent(contextInfo.quotedMessage),
|
||||
type: quotedType,
|
||||
fromMe: contextInfo.participant === raw.key?.participant || false,
|
||||
participant: contextInfo.participant,
|
||||
participantName: null // Will be filled from contacts if needed
|
||||
}
|
||||
|
||||
// Add media thumbnail for quoted media messages
|
||||
if (['image', 'video', 'sticker'].includes(quotedType)) {
|
||||
const quotedMedia = contextInfo.quotedMessage[`${quotedType}Message`]
|
||||
if (quotedMedia?.jpegThumbnail) {
|
||||
result.quoted.media = {
|
||||
thumbnail: Buffer.from(quotedMedia.jpegThumbnail).toString('base64')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract participant for group messages
|
||||
if (raw.key?.participant) {
|
||||
result.participant = raw.key.participant
|
||||
}
|
||||
|
||||
// Extract pushName
|
||||
if (raw.pushName) {
|
||||
result.pushName = raw.pushName
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Get context info from any message type
|
||||
function getContextInfo(msg: any): any {
|
||||
const messageTypes = [
|
||||
'extendedTextMessage',
|
||||
'imageMessage',
|
||||
'videoMessage',
|
||||
'audioMessage',
|
||||
'documentMessage',
|
||||
'stickerMessage',
|
||||
'contactMessage',
|
||||
'locationMessage'
|
||||
]
|
||||
|
||||
for (const type of messageTypes) {
|
||||
if (msg[type]?.contextInfo) {
|
||||
return msg[type].contextInfo
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Get message type from message content
|
||||
function getMessageType(msg: any): string {
|
||||
if (msg.conversation || msg.extendedTextMessage) return 'text'
|
||||
if (msg.imageMessage) return 'image'
|
||||
if (msg.videoMessage) return 'video'
|
||||
if (msg.audioMessage) return 'audio'
|
||||
if (msg.documentMessage) return 'document'
|
||||
if (msg.stickerMessage) return 'sticker'
|
||||
if (msg.contactMessage) return 'contact'
|
||||
if (msg.locationMessage) return 'location'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
// Extract text content from any message type
|
||||
function extractTextContent(msg: any): string | null {
|
||||
if (msg.conversation) return msg.conversation
|
||||
if (msg.extendedTextMessage?.text) return msg.extendedTextMessage.text
|
||||
if (msg.imageMessage?.caption) return msg.imageMessage.caption
|
||||
if (msg.videoMessage?.caption) return msg.videoMessage.caption
|
||||
if (msg.documentMessage?.caption) return msg.documentMessage.caption
|
||||
return null
|
||||
}
|
||||
|
||||
// Extract phone numbers from vCard
|
||||
function extractPhonesFromVCard(vcard: string): string[] {
|
||||
const phones: string[] = []
|
||||
const telRegex = /TEL[^:]*:([^\n\r]+)/gi
|
||||
let match
|
||||
|
||||
while ((match = telRegex.exec(vcard)) !== null) {
|
||||
const phone = match[1].trim().replace(/[^\d+]/g, '')
|
||||
if (phone) phones.push(phone)
|
||||
}
|
||||
|
||||
return phones
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
@@ -30,26 +225,38 @@ export default defineEventHandler(async (event) => {
|
||||
const queryParams = getQuery(event)
|
||||
const limit = Math.min(parseInt(queryParams.limit as string) || 50, 100)
|
||||
const offset = parseInt(queryParams.offset as string) || 0
|
||||
const before = queryParams.before as string // For infinite scroll
|
||||
|
||||
// Verify chat exists and belongs to instance
|
||||
const chatCheck = await query(
|
||||
'SELECT id FROM chats WHERE id = $1 AND instance_id = $2',
|
||||
// Verify chat exists and get info
|
||||
const chatCheck = await query<ChatRow>(
|
||||
'SELECT id, is_group FROM chats WHERE id = $1 AND instance_id = $2',
|
||||
[chatId, instanceId]
|
||||
)
|
||||
if (chatCheck.rows.length === 0) {
|
||||
throw createError({ statusCode: 404, message: 'Chat not found' })
|
||||
}
|
||||
|
||||
// Get messages
|
||||
const result = await query<MessageRow>(
|
||||
`SELECT id, message_id, from_jid, from_me, message_type,
|
||||
content, caption, media_url, timestamp, status
|
||||
FROM messages
|
||||
WHERE chat_id = $1
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT $2 OFFSET $3`,
|
||||
[chatId, limit, offset]
|
||||
)
|
||||
const isGroup = chatCheck.rows[0].is_group
|
||||
|
||||
// Build query with optional before parameter for infinite scroll
|
||||
let messagesQuery = `
|
||||
SELECT id, message_id, from_jid, from_me, message_type,
|
||||
content, caption, media_url, timestamp, status,
|
||||
raw_message, participant_jid, push_name, quoted_message_id
|
||||
FROM messages
|
||||
WHERE chat_id = $1
|
||||
`
|
||||
const params: any[] = [chatId]
|
||||
|
||||
if (before) {
|
||||
messagesQuery += ` AND timestamp < $${params.length + 1}`
|
||||
params.push(before)
|
||||
}
|
||||
|
||||
messagesQuery += ` ORDER BY timestamp DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`
|
||||
params.push(limit, offset)
|
||||
|
||||
const result = await query<MessageRow>(messagesQuery, params)
|
||||
|
||||
// Mark as read
|
||||
await query(
|
||||
@@ -57,16 +264,28 @@ export default defineEventHandler(async (event) => {
|
||||
[chatId]
|
||||
)
|
||||
|
||||
return result.rows.map(row => ({
|
||||
id: row.id,
|
||||
messageId: row.message_id,
|
||||
fromJid: row.from_jid,
|
||||
fromMe: row.from_me,
|
||||
type: row.message_type,
|
||||
content: row.content,
|
||||
caption: row.caption,
|
||||
mediaUrl: row.media_url,
|
||||
timestamp: row.timestamp,
|
||||
status: row.status
|
||||
}))
|
||||
// Parse and return messages
|
||||
return result.rows.map(row => {
|
||||
const parsedData = parseRawMessage(row.raw_message, row.message_type)
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
messageId: row.message_id,
|
||||
chatId: chatId,
|
||||
fromJid: row.from_jid,
|
||||
fromMe: row.from_me,
|
||||
type: row.message_type || 'unknown',
|
||||
content: row.content,
|
||||
caption: row.caption || parsedData.caption,
|
||||
media: parsedData.media || (row.media_url ? { url: row.media_url } : undefined),
|
||||
location: parsedData.location,
|
||||
contact: parsedData.contact,
|
||||
quoted: parsedData.quoted,
|
||||
timestamp: row.timestamp,
|
||||
status: row.status || 'sent',
|
||||
participant: row.participant_jid || parsedData.participant,
|
||||
pushName: row.push_name || parsedData.pushName,
|
||||
isGroup: isGroup
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
234
server/api/messages/[instanceId]/[chatId]/send-media.post.ts
Normal file
234
server/api/messages/[instanceId]/[chatId]/send-media.post.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* POST /api/messages/:instanceId/:chatId/send-media
|
||||
* Send media messages (images, videos, audio, documents)
|
||||
*/
|
||||
import { prepareWAMessageMedia, type AnyMediaMessageContent } from '@whiskeysockets/baileys'
|
||||
import { baileysManager } from '../../../../services/baileys/manager'
|
||||
import { query } from '../../../../utils/database'
|
||||
|
||||
// Max file sizes (in bytes)
|
||||
const MAX_SIZES = {
|
||||
image: 16 * 1024 * 1024, // 16 MB
|
||||
video: 64 * 1024 * 1024, // 64 MB
|
||||
audio: 16 * 1024 * 1024, // 16 MB
|
||||
document: 100 * 1024 * 1024, // 100 MB
|
||||
}
|
||||
|
||||
// MIME type to media type mapping
|
||||
function getMediaType(mimetype: string): 'image' | 'video' | 'audio' | 'document' | null {
|
||||
if (mimetype.startsWith('image/')) return 'image'
|
||||
if (mimetype.startsWith('video/')) return 'video'
|
||||
if (mimetype.startsWith('audio/')) return 'audio'
|
||||
// Everything else is a document
|
||||
return 'document'
|
||||
}
|
||||
|
||||
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 chatId = getRouterParam(event, 'chatId')
|
||||
|
||||
if (!instanceId || !chatId) {
|
||||
throw createError({ statusCode: 400, message: 'Missing instanceId or chatId' })
|
||||
}
|
||||
|
||||
// Get chat JID
|
||||
const chatResult = await query(
|
||||
'SELECT jid FROM chats WHERE id = $1 AND instance_id = $2',
|
||||
[chatId, instanceId]
|
||||
)
|
||||
|
||||
if (chatResult.rows.length === 0) {
|
||||
throw createError({ statusCode: 404, message: 'Chat not found' })
|
||||
}
|
||||
|
||||
const jid = chatResult.rows[0].jid
|
||||
|
||||
// Get socket
|
||||
const socket = baileysManager.getSocket(instanceId)
|
||||
if (!socket) {
|
||||
throw createError({ statusCode: 400, message: 'Instance not connected' })
|
||||
}
|
||||
|
||||
// Parse multipart form data
|
||||
const formData = await readMultipartFormData(event)
|
||||
if (!formData) {
|
||||
throw createError({ statusCode: 400, message: 'No form data received' })
|
||||
}
|
||||
|
||||
// Extract fields
|
||||
let caption = ''
|
||||
let quotedMessageId = ''
|
||||
let isPtt = false
|
||||
const files: { name: string; data: Buffer; type: string }[] = []
|
||||
|
||||
for (const item of formData) {
|
||||
if (item.name === 'caption' && item.data) {
|
||||
caption = item.data.toString()
|
||||
} else if (item.name === 'quotedMessageId' && item.data) {
|
||||
quotedMessageId = item.data.toString()
|
||||
} else if (item.name === 'isPtt' && item.data) {
|
||||
isPtt = item.data.toString() === 'true'
|
||||
} else if (item.name === 'files' || item.name === 'file') {
|
||||
if (item.data && item.type) {
|
||||
files.push({
|
||||
name: item.filename || 'file',
|
||||
data: item.data,
|
||||
type: item.type
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
throw createError({ statusCode: 400, message: 'No files provided' })
|
||||
}
|
||||
|
||||
// Get quoted message if provided
|
||||
let quotedMessage = null
|
||||
if (quotedMessageId) {
|
||||
const quotedResult = await query(
|
||||
'SELECT raw_message FROM messages WHERE message_id = $1 AND instance_id = $2',
|
||||
[quotedMessageId, instanceId]
|
||||
)
|
||||
if (quotedResult.rows.length > 0) {
|
||||
quotedMessage = quotedResult.rows[0].raw_message
|
||||
}
|
||||
}
|
||||
|
||||
const sentMessages = []
|
||||
|
||||
// Send each file
|
||||
for (const file of files) {
|
||||
const mediaType = getMediaType(file.type)
|
||||
|
||||
if (!mediaType) {
|
||||
console.warn(`[SendMedia] Unknown media type: ${file.type}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check file size
|
||||
const maxSize = MAX_SIZES[mediaType]
|
||||
if (file.data.length > maxSize) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: `File ${file.name} exceeds maximum size for ${mediaType}`
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// Prepare media content
|
||||
let content: AnyMediaMessageContent
|
||||
|
||||
if (mediaType === 'image') {
|
||||
content = {
|
||||
image: file.data,
|
||||
caption: caption || undefined,
|
||||
mimetype: file.type as any
|
||||
}
|
||||
} else if (mediaType === 'video') {
|
||||
content = {
|
||||
video: file.data,
|
||||
caption: caption || undefined,
|
||||
mimetype: file.type as any
|
||||
}
|
||||
} else if (mediaType === 'audio') {
|
||||
content = {
|
||||
audio: file.data,
|
||||
ptt: isPtt,
|
||||
mimetype: file.type as any
|
||||
}
|
||||
} else {
|
||||
// Document
|
||||
content = {
|
||||
document: file.data,
|
||||
fileName: file.name,
|
||||
caption: caption || undefined,
|
||||
mimetype: file.type as any
|
||||
}
|
||||
}
|
||||
|
||||
// Add quoted message if exists
|
||||
if (quotedMessage) {
|
||||
(content as any).quoted = quotedMessage
|
||||
}
|
||||
|
||||
// Send message
|
||||
console.log(`[SendMedia] Sending ${mediaType} to ${jid}`)
|
||||
const result = await socket.sendMessage(jid, content)
|
||||
|
||||
if (result) {
|
||||
sentMessages.push({
|
||||
messageId: result.key.id,
|
||||
type: mediaType,
|
||||
filename: file.name
|
||||
})
|
||||
|
||||
// Save to database
|
||||
await saveMediaMessage(instanceId, chatId, jid, result, mediaType, caption, file.name)
|
||||
}
|
||||
|
||||
// Only use caption for first file
|
||||
caption = ''
|
||||
} catch (error) {
|
||||
console.error(`[SendMedia] Error sending ${file.name}:`, error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: `Error sending ${file.name}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messages: sentMessages
|
||||
}
|
||||
})
|
||||
|
||||
// Helper to save media message to database
|
||||
async function saveMediaMessage(
|
||||
instanceId: string,
|
||||
chatId: string,
|
||||
jid: string,
|
||||
result: any,
|
||||
messageType: string,
|
||||
caption: string,
|
||||
filename: string
|
||||
) {
|
||||
const messageId = result.key.id
|
||||
const timestamp = new Date()
|
||||
|
||||
await query(
|
||||
`INSERT INTO messages (
|
||||
instance_id, chat_id, message_id, from_jid, to_jid,
|
||||
from_me, message_type, content, caption, media_filename,
|
||||
timestamp, status, raw_message
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
ON CONFLICT (instance_id, message_id) DO NOTHING`,
|
||||
[
|
||||
instanceId,
|
||||
chatId,
|
||||
messageId,
|
||||
jid, // from_jid will be our JID
|
||||
jid, // to_jid
|
||||
true, // from_me
|
||||
messageType,
|
||||
caption || null,
|
||||
caption || null,
|
||||
filename,
|
||||
timestamp,
|
||||
'sent',
|
||||
JSON.stringify(result)
|
||||
]
|
||||
)
|
||||
|
||||
// Update chat last message
|
||||
await query(
|
||||
`UPDATE chats SET last_message_at = $1, last_message_type = $2 WHERE id = $3`,
|
||||
[timestamp, messageType, chatId]
|
||||
)
|
||||
}
|
||||
85
server/database/migrations/002_messages_enhanced.sql
Normal file
85
server/database/migrations/002_messages_enhanced.sql
Normal file
@@ -0,0 +1,85 @@
|
||||
-- =====================================================
|
||||
-- Migration 002: Enhanced Messages Support
|
||||
-- =====================================================
|
||||
-- Adds fields for:
|
||||
-- - Group participant tracking (participant_jid, push_name)
|
||||
-- - Media caching (media_cached, media_local_path, media_size_bytes)
|
||||
-- - Message reactions
|
||||
-- - Presence caching
|
||||
-- =====================================================
|
||||
|
||||
-- Add participant tracking fields to messages
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS participant_jid VARCHAR(100);
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS push_name VARCHAR(255);
|
||||
|
||||
-- Add media caching fields
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS media_cached BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS media_local_path TEXT;
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS media_size_bytes BIGINT;
|
||||
|
||||
-- Create table for message reactions
|
||||
CREATE TABLE IF NOT EXISTS message_reactions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
instance_id UUID NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
|
||||
reactor_jid VARCHAR(100) NOT NULL,
|
||||
reactor_name VARCHAR(255),
|
||||
emoji VARCHAR(20) NOT NULL,
|
||||
timestamp TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT unique_reaction UNIQUE (message_id, reactor_jid)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reactions_message ON message_reactions(message_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_reactions_instance ON message_reactions(instance_id);
|
||||
|
||||
-- Create table for presence caching (optional, for "last seen" persistence)
|
||||
CREATE TABLE IF NOT EXISTS presence_cache (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
instance_id UUID NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
|
||||
jid VARCHAR(100) NOT NULL,
|
||||
presence VARCHAR(20) CHECK (presence IN ('available', 'unavailable', 'composing', 'recording', 'paused')),
|
||||
last_seen TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT unique_presence UNIQUE (instance_id, jid)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_presence_instance ON presence_cache(instance_id);
|
||||
|
||||
-- Create table for group metadata caching
|
||||
CREATE TABLE IF NOT EXISTS group_metadata (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
instance_id UUID NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
|
||||
jid VARCHAR(100) NOT NULL,
|
||||
subject VARCHAR(255),
|
||||
description TEXT,
|
||||
owner_jid VARCHAR(100),
|
||||
participants JSONB DEFAULT '[]',
|
||||
announce_only BOOLEAN DEFAULT FALSE,
|
||||
restrict_edit BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT unique_group UNIQUE (instance_id, jid)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_group_metadata_instance ON group_metadata(instance_id);
|
||||
|
||||
-- Add trigger for presence_cache updated_at
|
||||
CREATE TRIGGER update_presence_cache_updated_at
|
||||
BEFORE UPDATE ON presence_cache
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Add trigger for group_metadata updated_at
|
||||
CREATE TRIGGER update_group_metadata_updated_at
|
||||
BEFORE UPDATE ON group_metadata
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Add index for media caching lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_media_cached ON messages(media_cached) WHERE media_cached = TRUE;
|
||||
|
||||
-- Add last_message_type to chats for preview icons
|
||||
ALTER TABLE chats ADD COLUMN IF NOT EXISTS last_message_type VARCHAR(50);
|
||||
@@ -47,6 +47,8 @@ export interface InstanceEvents {
|
||||
'message.received': { instanceId: string; message: any }
|
||||
'message.sent': { instanceId: string; message: any }
|
||||
'message.status': { instanceId: string; messageId: string; status: string }
|
||||
'message.reaction': { instanceId: string; reaction: any }
|
||||
'presence.update': { instanceId: string; jid: string; presences: Record<string, { lastKnownPresence: string; lastSeen?: number }> }
|
||||
}
|
||||
|
||||
const logger = pino({ level: 'warn' })
|
||||
@@ -372,6 +374,74 @@ class BaileysManager extends EventEmitter {
|
||||
}
|
||||
})
|
||||
|
||||
// Presence update
|
||||
socket.ev.on('presence.update', async (update) => {
|
||||
console.log(`[BaileysManager] presence.update:`, JSON.stringify(update))
|
||||
this.emit('presence.update', {
|
||||
instanceId,
|
||||
jid: update.id,
|
||||
presences: update.presences
|
||||
})
|
||||
|
||||
// Cache presence in database
|
||||
for (const [participantJid, presence] of Object.entries(update.presences)) {
|
||||
try {
|
||||
await query(
|
||||
`INSERT INTO presence_cache (instance_id, jid, presence, last_seen)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (instance_id, jid) DO UPDATE SET
|
||||
presence = EXCLUDED.presence,
|
||||
last_seen = COALESCE(EXCLUDED.last_seen, presence_cache.last_seen)`,
|
||||
[
|
||||
instanceId,
|
||||
participantJid,
|
||||
presence.lastKnownPresence,
|
||||
presence.lastSeen ? new Date(presence.lastSeen * 1000) : null
|
||||
]
|
||||
)
|
||||
} catch (err) {
|
||||
console.error(`[BaileysManager] Error caching presence:`, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Message reactions
|
||||
socket.ev.on('messages.reaction', async (reactions) => {
|
||||
for (const reaction of reactions) {
|
||||
console.log(`[BaileysManager] message.reaction:`, JSON.stringify(reaction))
|
||||
this.emit('message.reaction', { instanceId, reaction })
|
||||
|
||||
// Save reaction to database
|
||||
try {
|
||||
const { key, reaction: reactionData } = reaction
|
||||
if (reactionData.text) {
|
||||
// Add reaction
|
||||
await query(
|
||||
`INSERT INTO message_reactions (message_id, reactor_jid, emoji)
|
||||
SELECT m.id, $2, $3
|
||||
FROM messages m
|
||||
WHERE m.instance_id = $1 AND m.message_id = $4
|
||||
ON CONFLICT (message_id, reactor_jid) DO UPDATE SET
|
||||
emoji = EXCLUDED.emoji,
|
||||
timestamp = NOW()`,
|
||||
[instanceId, key.participant || key.fromMe ? 'me' : key.remoteJid, reactionData.text, reactionData.key.id]
|
||||
)
|
||||
} else {
|
||||
// Remove reaction (empty text)
|
||||
await query(
|
||||
`DELETE FROM message_reactions
|
||||
WHERE message_id IN (
|
||||
SELECT id FROM messages WHERE instance_id = $1 AND message_id = $2
|
||||
) AND reactor_jid = $3`,
|
||||
[instanceId, reactionData.key.id, key.participant || key.fromMe ? 'me' : key.remoteJid]
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[BaileysManager] Error saving reaction:`, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// History sync - save chats and messages from history
|
||||
socket.ev.on('messaging-history.set', async ({ chats, contacts, messages, syncType }) => {
|
||||
console.log(`[BaileysManager] History sync received: ${chats?.length || 0} chats, ${contacts?.length || 0} contacts, ${messages?.length || 0} messages, type: ${syncType}`)
|
||||
@@ -477,6 +547,53 @@ class BaileysManager extends EventEmitter {
|
||||
return managed.socket
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to presence updates for a JID
|
||||
*/
|
||||
async subscribeToPresence(instanceId: string, jid: string): Promise<void> {
|
||||
const managed = this.instances.get(instanceId)
|
||||
if (!managed?.socket) {
|
||||
throw new Error('Instance not connected')
|
||||
}
|
||||
|
||||
await managed.socket.presenceSubscribe(jid)
|
||||
console.log(`[BaileysManager] Subscribed to presence: ${jid}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send presence update (composing, recording, available, unavailable, paused)
|
||||
*/
|
||||
async sendPresence(instanceId: string, jid: string, presence: 'composing' | 'recording' | 'available' | 'unavailable' | 'paused'): Promise<void> {
|
||||
const managed = this.instances.get(instanceId)
|
||||
if (!managed?.socket) {
|
||||
throw new Error('Instance not connected')
|
||||
}
|
||||
|
||||
await managed.socket.sendPresenceUpdate(presence, jid)
|
||||
console.log(`[BaileysManager] Sent presence ${presence} to ${jid}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a reaction to a message
|
||||
*/
|
||||
async sendReaction(instanceId: string, jid: string, messageId: string, emoji: string): Promise<void> {
|
||||
const managed = this.instances.get(instanceId)
|
||||
if (!managed?.socket) {
|
||||
throw new Error('Instance not connected')
|
||||
}
|
||||
|
||||
await managed.socket.sendMessage(jid, {
|
||||
react: {
|
||||
text: emoji,
|
||||
key: {
|
||||
remoteJid: jid,
|
||||
id: messageId
|
||||
}
|
||||
}
|
||||
})
|
||||
console.log(`[BaileysManager] Sent reaction ${emoji} to message ${messageId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update instance status in database
|
||||
*/
|
||||
|
||||
314
server/services/media/downloader.ts
Normal file
314
server/services/media/downloader.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Media Downloader Service
|
||||
* Downloads and caches media from WhatsApp messages using Baileys
|
||||
*/
|
||||
import { downloadMediaMessage, type WAMessage } from '@whiskeysockets/baileys'
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import { query } from '../../utils/database'
|
||||
import { baileysManager } from '../baileys/manager'
|
||||
|
||||
// Storage directory for cached media
|
||||
const STORAGE_DIR = process.env.MEDIA_STORAGE_PATH || './storage/media'
|
||||
|
||||
// MIME type to extension mapping
|
||||
const MIME_EXTENSIONS: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/gif': 'gif',
|
||||
'image/webp': 'webp',
|
||||
'video/mp4': 'mp4',
|
||||
'video/3gpp': '3gp',
|
||||
'video/quicktime': 'mov',
|
||||
'audio/ogg': 'ogg',
|
||||
'audio/mpeg': 'mp3',
|
||||
'audio/mp4': 'm4a',
|
||||
'audio/aac': 'aac',
|
||||
'audio/opus': 'opus',
|
||||
'application/pdf': 'pdf',
|
||||
'application/msword': 'doc',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
|
||||
'application/vnd.ms-excel': 'xls',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
|
||||
'application/vnd.ms-powerpoint': 'ppt',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
|
||||
'application/zip': 'zip',
|
||||
'application/x-rar-compressed': 'rar',
|
||||
'text/plain': 'txt',
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension from MIME type
|
||||
*/
|
||||
function getExtensionFromMime(mimetype: string): string {
|
||||
return MIME_EXTENSIONS[mimetype] || mimetype.split('/')[1] || 'bin'
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure storage directory exists
|
||||
*/
|
||||
async function ensureStorageDir(instanceId: string): Promise<string> {
|
||||
const dir = path.join(STORAGE_DIR, instanceId)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
return dir
|
||||
}
|
||||
|
||||
/**
|
||||
* Get message type from raw message
|
||||
*/
|
||||
function getMediaType(rawMessage: any): string | null {
|
||||
const msg = rawMessage?.message
|
||||
if (!msg) return null
|
||||
|
||||
if (msg.imageMessage) return 'image'
|
||||
if (msg.videoMessage) return 'video'
|
||||
if (msg.audioMessage) return 'audio'
|
||||
if (msg.documentMessage) return 'document'
|
||||
if (msg.stickerMessage) return 'sticker'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type from raw message
|
||||
*/
|
||||
function getMimeType(rawMessage: any, mediaType: string): string {
|
||||
const msg = rawMessage?.message
|
||||
if (!msg) return 'application/octet-stream'
|
||||
|
||||
const messageData = msg[`${mediaType}Message`]
|
||||
return messageData?.mimetype || 'application/octet-stream'
|
||||
}
|
||||
|
||||
/**
|
||||
* Download media from a message and cache it locally
|
||||
*/
|
||||
export async function downloadAndCacheMedia(
|
||||
instanceId: string,
|
||||
messageId: string
|
||||
): Promise<{ path: string; mimetype: string; size: number } | null> {
|
||||
// Get message from database
|
||||
const result = await query(
|
||||
`SELECT id, raw_message, message_type, media_cached, media_local_path, media_mimetype
|
||||
FROM messages
|
||||
WHERE instance_id = $1 AND message_id = $2`,
|
||||
[instanceId, messageId]
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
console.error(`[Media] Message not found: ${messageId}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const message = result.rows[0]
|
||||
|
||||
// If already cached, return cached path
|
||||
if (message.media_cached && message.media_local_path) {
|
||||
try {
|
||||
await fs.access(message.media_local_path)
|
||||
return {
|
||||
path: message.media_local_path,
|
||||
mimetype: message.media_mimetype || 'application/octet-stream',
|
||||
size: 0 // Could read file size if needed
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist, re-download
|
||||
console.log(`[Media] Cached file not found, re-downloading: ${messageId}`)
|
||||
}
|
||||
}
|
||||
|
||||
const rawMessage = message.raw_message
|
||||
if (!rawMessage?.message) {
|
||||
console.error(`[Media] No raw message content for: ${messageId}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const mediaType = getMediaType(rawMessage)
|
||||
if (!mediaType) {
|
||||
console.error(`[Media] Message is not a media message: ${messageId}`)
|
||||
return null
|
||||
}
|
||||
|
||||
// Get socket for this instance
|
||||
const socket = baileysManager.getSocket(instanceId)
|
||||
if (!socket) {
|
||||
console.error(`[Media] No active socket for instance: ${instanceId}`)
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
// Download media using Baileys
|
||||
console.log(`[Media] Downloading ${mediaType} for message: ${messageId}`)
|
||||
|
||||
const buffer = await downloadMediaMessage(
|
||||
rawMessage as WAMessage,
|
||||
'buffer',
|
||||
{},
|
||||
{
|
||||
logger: console as any,
|
||||
reuploadRequest: socket.updateMediaMessage
|
||||
}
|
||||
)
|
||||
|
||||
if (!buffer || buffer.length === 0) {
|
||||
console.error(`[Media] Empty buffer received for: ${messageId}`)
|
||||
return null
|
||||
}
|
||||
|
||||
// Determine file extension
|
||||
const mimetype = getMimeType(rawMessage, mediaType)
|
||||
const extension = getExtensionFromMime(mimetype)
|
||||
|
||||
// Ensure storage directory exists
|
||||
const storageDir = await ensureStorageDir(instanceId)
|
||||
|
||||
// Save file
|
||||
const filename = `${messageId}.${extension}`
|
||||
const filePath = path.join(storageDir, filename)
|
||||
|
||||
await fs.writeFile(filePath, buffer)
|
||||
|
||||
// Update database with cache info
|
||||
await query(
|
||||
`UPDATE messages
|
||||
SET media_cached = TRUE,
|
||||
media_local_path = $1,
|
||||
media_mimetype = $2,
|
||||
media_size_bytes = $3
|
||||
WHERE instance_id = $4 AND message_id = $5`,
|
||||
[filePath, mimetype, buffer.length, instanceId, messageId]
|
||||
)
|
||||
|
||||
console.log(`[Media] Cached: ${filePath} (${buffer.length} bytes)`)
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
mimetype,
|
||||
size: buffer.length
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Media] Error downloading media for ${messageId}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached media path for a message
|
||||
* Returns null if not cached
|
||||
*/
|
||||
export async function getCachedMediaPath(
|
||||
instanceId: string,
|
||||
messageId: string
|
||||
): Promise<{ path: string; mimetype: string } | null> {
|
||||
const result = await query(
|
||||
`SELECT media_local_path, media_mimetype
|
||||
FROM messages
|
||||
WHERE instance_id = $1 AND message_id = $2 AND media_cached = TRUE`,
|
||||
[instanceId, messageId]
|
||||
)
|
||||
|
||||
if (result.rows.length === 0 || !result.rows[0].media_local_path) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Verify file exists
|
||||
try {
|
||||
await fs.access(result.rows[0].media_local_path)
|
||||
return {
|
||||
path: result.rows[0].media_local_path,
|
||||
mimetype: result.rows[0].media_mimetype || 'application/octet-stream'
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist, clear cache flag
|
||||
await query(
|
||||
`UPDATE messages SET media_cached = FALSE, media_local_path = NULL
|
||||
WHERE instance_id = $1 AND message_id = $2`,
|
||||
[instanceId, messageId]
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete cached media for a message
|
||||
*/
|
||||
export async function deleteCachedMedia(
|
||||
instanceId: string,
|
||||
messageId: string
|
||||
): Promise<boolean> {
|
||||
const result = await query(
|
||||
`SELECT media_local_path FROM messages
|
||||
WHERE instance_id = $1 AND message_id = $2 AND media_cached = TRUE`,
|
||||
[instanceId, messageId]
|
||||
)
|
||||
|
||||
if (result.rows.length === 0 || !result.rows[0].media_local_path) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.unlink(result.rows[0].media_local_path)
|
||||
} catch {
|
||||
// File might not exist, that's ok
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE messages
|
||||
SET media_cached = FALSE, media_local_path = NULL, media_size_bytes = NULL
|
||||
WHERE instance_id = $1 AND message_id = $2`,
|
||||
[instanceId, messageId]
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total size of cached media for an instance
|
||||
*/
|
||||
export async function getCacheStats(instanceId: string): Promise<{
|
||||
count: number
|
||||
totalSize: number
|
||||
}> {
|
||||
const result = await query(
|
||||
`SELECT COUNT(*) as count, COALESCE(SUM(media_size_bytes), 0) as total_size
|
||||
FROM messages
|
||||
WHERE instance_id = $1 AND media_cached = TRUE`,
|
||||
[instanceId]
|
||||
)
|
||||
|
||||
return {
|
||||
count: parseInt(result.rows[0].count) || 0,
|
||||
totalSize: parseInt(result.rows[0].total_size) || 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached media for an instance
|
||||
*/
|
||||
export async function clearInstanceCache(instanceId: string): Promise<number> {
|
||||
// Get all cached files
|
||||
const result = await query(
|
||||
`SELECT media_local_path FROM messages
|
||||
WHERE instance_id = $1 AND media_cached = TRUE AND media_local_path IS NOT NULL`,
|
||||
[instanceId]
|
||||
)
|
||||
|
||||
let deleted = 0
|
||||
for (const row of result.rows) {
|
||||
try {
|
||||
await fs.unlink(row.media_local_path)
|
||||
deleted++
|
||||
} catch {
|
||||
// File might not exist
|
||||
}
|
||||
}
|
||||
|
||||
// Clear cache flags
|
||||
await query(
|
||||
`UPDATE messages
|
||||
SET media_cached = FALSE, media_local_path = NULL, media_size_bytes = NULL
|
||||
WHERE instance_id = $1`,
|
||||
[instanceId]
|
||||
)
|
||||
|
||||
return deleted
|
||||
}
|
||||
Reference in New Issue
Block a user