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 <noreply@anthropic.com>
This commit is contained in:
Renato Alcara
2026-01-23 14:41:19 -03:00
parent 32d8b57605
commit 6e0694e72a
3 changed files with 395 additions and 0 deletions
+262
View File
@@ -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<AlbumSendResult> => {
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<AlbumMediaResult> => {
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 (
+94
View File
@@ -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
) &
+39
View File
@@ -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