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

- 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:
2025-12-02 21:21:33 -06:00
parent 71593b25e9
commit 80d0042c7e
21 changed files with 3722 additions and 112 deletions

View 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'
})
}
})

View File

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

View 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]
)
}

View 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);

View File

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

View 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
}