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:
@@ -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