From 6e0694e72a723159c9b2a436f42d2088b2844028 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Fri, 23 Jan 2026 14:41:19 -0300 Subject: [PATCH] feat(album): add album message sending with intelligent retry and adaptive delay Implements WhatsApp album messages (grouped media) with the following features: - Send 2-10 images/videos grouped as a single album message - Adaptive delay between sends based on media type (videos get 2x delay) - Intelligent retry with exponential backoff for failed items - parentMessageKey reference to album root (as suggested by maintainer) - Complete result structure with success/failure tracking per item - Validation for min (2) and max (10) media items Types added: - AlbumMediaItem: Single image/video with caption, mentions, dimensions - AlbumMessageOptions: Configuration for delay, retry, continueOnFailure - AlbumMediaResult: Per-item result with latency and retry attempts - AlbumSendResult: Complete result with albumKey and statistics Based on PR #2058 from WhiskeySockets/Baileys with improvements. Co-Authored-By: Claude Opus 4.5 --- src/Socket/messages-send.ts | 262 ++++++++++++++++++++++++++++++++++++ src/Types/Message.ts | 94 +++++++++++++ src/Utils/messages.ts | 39 ++++++ 3 files changed, 395 insertions(+) diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index 9660b40b..f8444a6a 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -3,6 +3,10 @@ import { Boom } from '@hapi/boom' import { proto } from '../../WAProto/index.js' import { DEFAULT_CACHE_TTLS, WA_DEFAULT_EPHEMERAL } from '../Defaults' import type { + AlbumMediaItem, + AlbumMediaResult, + AlbumMessageOptions, + AlbumSendResult, AnyMessageContent, MediaConnInfo, MessageReceiptType, @@ -1290,6 +1294,264 @@ export const makeMessagesSocket = (config: SocketConfig) => { return message }, + + /** + * Send an album message (multiple images/videos grouped together) + * + * @param jid - The recipient JID + * @param album - Album configuration with media items + * @param options - Additional message generation options + * @returns Complete result with status of each media item + * + * @example + * ```typescript + * const result = await sock.sendAlbumMessage('1234567890@s.whatsapp.net', { + * medias: [ + * { image: { url: './photo1.jpg' }, caption: 'First photo' }, + * { image: { url: './photo2.jpg' }, caption: 'Second photo' }, + * { video: { url: './video.mp4' }, caption: 'A video' } + * ], + * delay: 'adaptive', // or fixed ms like 500 + * retryCount: 3, + * continueOnFailure: true + * }) + * + * if (!result.success) { + * console.log('Failed items:', result.failedIndices) + * } + * ``` + */ + sendAlbumMessage: async ( + jid: string, + album: AlbumMessageOptions, + options: MiscMessageGenerationOptions = {} + ): Promise => { + const startTime = Date.now() + const userJid = authState.creds.me!.id + + const { + medias, + delay: delayConfig = 'adaptive', + retryCount = 3, + continueOnFailure = true + } = album + + // Validation (also done in generateWAMessageContent, but double-check here) + if (!medias || medias.length < 2) { + throw new Boom('Album must have at least 2 media items', { statusCode: 400 }) + } + if (medias.length > 10) { + throw new Boom('Album cannot have more than 10 media items (WhatsApp limit)', { statusCode: 400 }) + } + + // Count media types for album root + const imageCount = medias.filter(m => 'image' in m).length + const videoCount = medias.filter(m => 'video' in m).length + + logger.info( + { jid, totalItems: medias.length, imageCount, videoCount, delayConfig, retryCount }, + 'Starting album message send' + ) + + // Generate album root message first + const albumRootMsg = await generateWAMessage(jid, { + album: { medias, delay: delayConfig, retryCount, continueOnFailure } + }, { + logger, + userJid, + getUrlInfo: text => getUrlInfo(text, { + thumbnailWidth: linkPreviewImageThumbnailWidth, + fetchOpts: { timeout: 3_000, ...(httpRequestOptions || {}) }, + logger, + uploadImage: generateHighQualityLinkPreview ? waUploadToServer : undefined + }), + upload: waUploadToServer, + mediaCache: config.mediaCache, + ...options + }) + + const albumKey = albumRootMsg.key + const results: AlbumMediaResult[] = [] + + /** + * Calculate adaptive delay based on media characteristics + * Larger files and videos get more delay to prevent rate limiting + */ + const calculateAdaptiveDelay = (media: AlbumMediaItem, index: number): number => { + const baseDelay = 500 // Base delay in ms + + // Videos get more delay + const isVideo = 'video' in media + const mediaTypeMultiplier = isVideo ? 2.0 : 1.0 + + // Later items in album get slightly more delay (cumulative load) + const positionMultiplier = 1 + (index * 0.1) + + // Add some jitter to prevent predictable patterns + const jitter = Math.random() * 200 + + return Math.round(baseDelay * mediaTypeMultiplier * positionMultiplier + jitter) + } + + /** + * Get delay for this media item + */ + const getDelay = (media: AlbumMediaItem, index: number): number => { + if (delayConfig === 'adaptive') { + return calculateAdaptiveDelay(media, index) + } + return delayConfig + } + + /** + * Send a single media item with retry logic + */ + const sendMediaWithRetry = async ( + media: AlbumMediaItem, + index: number + ): Promise => { + const itemStartTime = Date.now() + let lastError: Error | undefined + let attempts = 0 + + for (let attempt = 0; attempt <= retryCount; attempt++) { + attempts = attempt + 1 + try { + // Generate message for this media item + const mediaMsg = await generateWAMessage(jid, media as AnyMessageContent, { + logger, + userJid, + getUrlInfo: text => getUrlInfo(text, { + thumbnailWidth: linkPreviewImageThumbnailWidth, + fetchOpts: { timeout: 3_000, ...(httpRequestOptions || {}) }, + logger, + uploadImage: generateHighQualityLinkPreview ? waUploadToServer : undefined + }), + upload: waUploadToServer, + mediaCache: config.mediaCache, + ...options + }) + + // Attach to parent album via messageContextInfo with parentKey + if (!mediaMsg.message!.messageContextInfo) { + mediaMsg.message!.messageContextInfo = {} + } + mediaMsg.message!.messageContextInfo.messageAddOnType = proto.MessageAddOnType.MEDIA_ALBUM + + // Add reference to parent album message (as suggested by purpshell) + // This allows proper grouping and retry of individual items + ;(mediaMsg.message!.messageContextInfo as any).parentMessageKey = albumKey + + // Relay the message + await relayMessage(jid, mediaMsg.message!, { + messageId: mediaMsg.key.id!, + useCachedGroupMetadata: options.useCachedGroupMetadata + }) + + // Emit own event if configured + if (config.emitOwnEvents) { + process.nextTick(async () => { + await messageMutex.mutex(() => upsertMessage(mediaMsg, 'append')) + }) + } + + logger.debug( + { index, msgId: mediaMsg.key.id, attempts }, + 'Album media item sent successfully' + ) + + return { + index, + success: true, + message: mediaMsg, + retryAttempts: attempts, + latencyMs: Date.now() - itemStartTime + } + } catch (error) { + lastError = error as Error + logger.warn( + { index, attempt: attempts, error: lastError.message }, + 'Album media item send failed, will retry' + ) + + // Exponential backoff for retries + if (attempt < retryCount) { + const backoffDelay = Math.min(1000 * Math.pow(2, attempt), 10000) + await new Promise(resolve => setTimeout(resolve, backoffDelay)) + } + } + } + + // All retries exhausted + logger.error( + { index, attempts, error: lastError?.message }, + 'Album media item failed after all retries' + ) + + return { + index, + success: false, + error: lastError, + retryAttempts: attempts, + latencyMs: Date.now() - itemStartTime + } + } + + // Send each media item sequentially + for (let i = 0; i < medias.length; i++) { + const media = medias[i] + + const result = await sendMediaWithRetry(media, i) + results.push(result) + + // Check if we should stop on failure + if (!result.success && !continueOnFailure) { + logger.warn( + { index: i, totalItems: medias.length }, + 'Album send stopped due to failure (continueOnFailure=false)' + ) + break + } + + // Apply delay before next item (except for last item) + if (i < medias.length - 1) { + const delay = getDelay(media, i) + logger.trace({ index: i, delayMs: delay }, 'Waiting before next album item') + await new Promise(resolve => setTimeout(resolve, delay)) + } + } + + // Calculate final results + const successCount = results.filter(r => r.success).length + const failedCount = results.filter(r => !r.success).length + const failedIndices = results.filter(r => !r.success).map(r => r.index) + const totalLatencyMs = Date.now() - startTime + + const finalResult: AlbumSendResult = { + albumKey, + results, + totalItems: medias.length, + successCount, + failedCount, + failedIndices, + success: failedCount === 0, + totalLatencyMs + } + + logger.info( + { + albumKeyId: albumKey.id, + totalItems: medias.length, + successCount, + failedCount, + totalLatencyMs + }, + 'Album message send completed' + ) + + return finalResult + }, + sendMessage: async (jid: string, content: AnyMessageContent, options: MiscMessageGenerationOptions = {}) => { const userJid = authState.creds.me!.id if ( diff --git a/src/Types/Message.ts b/src/Types/Message.ts index 22ecb9c6..b37fd315 100644 --- a/src/Types/Message.ts +++ b/src/Types/Message.ts @@ -286,6 +286,96 @@ export type Carouselable = { } } +// ========== Album Message Types ========== + +/** + * Single media item in an album (image or video) + * Each item can have its own caption, thumbnail, and metadata + */ +export type AlbumMediaItem = + | ({ + image: WAMediaUpload + caption?: string + jpegThumbnail?: string + } & Mentionable & + Contextable & + WithDimensions) + | ({ + video: WAMediaUpload + caption?: string + gifPlayback?: boolean + jpegThumbnail?: string + /** Duration in seconds */ + seconds?: number + } & Mentionable & + Contextable & + WithDimensions) + +/** + * Configuration for album message sending + */ +export type AlbumMessageOptions = { + /** Array of media items (images/videos) - min 2, max 10 */ + medias: AlbumMediaItem[] + /** + * Delay strategy between media sends + * - 'adaptive': Calculates delay based on media size (recommended) + * - number: Fixed delay in milliseconds + * @default 'adaptive' + */ + delay?: 'adaptive' | number + /** + * Number of retry attempts for failed media items + * @default 3 + */ + retryCount?: number + /** + * Whether to continue sending remaining items if one fails + * @default true + */ + continueOnFailure?: boolean +} + +/** + * Result of a single media item send attempt + */ +export type AlbumMediaResult = { + /** Index in the original medias array */ + index: number + /** Whether this item was sent successfully */ + success: boolean + /** The sent message (if successful) */ + message?: WAMessage + /** Error details (if failed) */ + error?: Error + /** Number of retry attempts made */ + retryAttempts: number + /** Time taken to send this item in ms */ + latencyMs: number +} + +/** + * Complete result of album message sending + */ +export type AlbumSendResult = { + /** Key of the album root message */ + albumKey: WAMessageKey + /** Results for each media item */ + results: AlbumMediaResult[] + /** Total number of items */ + totalItems: number + /** Number of successfully sent items */ + successCount: number + /** Number of failed items */ + failedCount: number + /** Indices of failed items (for potential retry) */ + failedIndices: number[] + /** Overall success (all items sent) */ + success: boolean + /** Total time taken in ms */ + totalLatencyMs: number +} + export type AnyRegularMessageContent = ( | ({ text: string @@ -338,6 +428,10 @@ export type AnyRegularMessageContent = ( body?: string footer?: string } + | { + /** Album message - send multiple images/videos grouped together */ + album: AlbumMessageOptions + } | SharePhoneNumber | RequestPhoneNumber ) & diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index 5f974742..759552b8 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -477,6 +477,45 @@ export const generateWAMessageContent = async ( m.interactiveMessage = interactiveMessage options.logger?.warn('[EXPERIMENTAL] Sending carouselMessage - this may not work and can cause bans') + } else if (hasNonNullishProperty(message, 'album')) { + // Album message validation - actual sending is handled in messages-send.ts + const { medias } = message.album + + // Validate minimum items (WhatsApp requirement) + if (!medias || medias.length < 2) { + throw new Boom('Album must have at least 2 media items', { statusCode: 400 }) + } + + // Validate maximum items (WhatsApp limit) + if (medias.length > 10) { + throw new Boom('Album cannot have more than 10 media items (WhatsApp limit)', { statusCode: 400 }) + } + + // Count and validate each media item + let expectedImageCount = 0 + let expectedVideoCount = 0 + + for (let i = 0; i < medias.length; i++) { + const media = medias[i] + if (hasNonNullishProperty(media, 'image')) { + expectedImageCount++ + } else if (hasNonNullishProperty(media, 'video')) { + expectedVideoCount++ + } else { + throw new Boom(`Album media at index ${i} must have 'image' or 'video' property`, { statusCode: 400 }) + } + } + + // Create album root message + m.albumMessage = WAProto.Message.AlbumMessage.create({ + expectedImageCount, + expectedVideoCount + }) + + options.logger?.info( + { expectedImageCount, expectedVideoCount, totalItems: medias.length }, + 'Album message validated - sending will be handled by relayMessage' + ) } else if (hasNonNullishProperty(message, 'text')) { // Normal text message processing const extContent = { text: message.text } as WATextMessage