diff --git a/src/Types/Message.ts b/src/Types/Message.ts index e78e8b45..12b43aad 100644 --- a/src/Types/Message.ts +++ b/src/Types/Message.ts @@ -54,12 +54,14 @@ export type WAMediaUpload = Buffer | WAMediaPayloadStream | WAMediaPayloadURL * Individual sticker in a sticker pack */ export type Sticker = { - /** Buffer, Stream or URL of the sticker image (will be converted to WebP if needed) */ + /** Buffer, Stream or URL of the sticker image (WebP or Lottie/WAS format) */ data: WAMediaUpload /** Array of emojis associated with this sticker (max 3 recommended per WhatsApp standards) */ emojis?: string[] /** Accessibility label for screen readers (max 125 chars for static, 255 for animated) */ accessibilityLabel?: string + /** Force Lottie format detection (auto-detected if omitted) */ + isLottie?: boolean } /** diff --git a/src/Utils/sticker-pack.ts b/src/Utils/sticker-pack.ts index f8f21b85..0b2727a0 100644 --- a/src/Utils/sticker-pack.ts +++ b/src/Utils/sticker-pack.ts @@ -2,6 +2,7 @@ import { Boom } from '@hapi/boom' import { createHash } from 'crypto' import { zipSync } from 'fflate' import { promises as fs } from 'fs' +import { gunzipSync } from 'zlib' import { proto } from '../../WAProto/index.js' import type { MediaType } from '../Defaults/index.js' import type { StickerPack, WAMediaUpload, WAMediaUploadFunction } from '../Types/Message.js' @@ -101,6 +102,48 @@ export const isAnimatedWebP = (buffer: Buffer): boolean => { return false } +/** + * Detecta se um buffer é Lottie JSON (raw ou gzip-compressed/WAS) + * + * WABA usa mimetype `application/was` para stickers Lottie. + * WAS (WhatsApp Animated Sticker) = gzip-compressed Lottie JSON. + * + * Detecção: + * - Gzip (0x1f 0x8b): descomprime e verifica se é Lottie JSON + * - JSON bruto: verifica campos Lottie obrigatórios (v, ip, op, layers) + * + * @param buffer - Buffer to check + * @returns true if buffer is Lottie/WAS format + */ +export const isLottieBuffer = (buffer: Buffer): boolean => { + if (buffer.length < 2) return false + + let jsonBuffer: Buffer + + // Check if gzip-compressed (WAS format) + if (buffer[0] === 0x1f && buffer[1] === 0x8b) { + try { + jsonBuffer = gunzipSync(buffer) as Buffer + } catch { + return false + } + } else if (buffer[0] === 0x7b) { + // Starts with '{' - could be raw Lottie JSON + jsonBuffer = buffer + } else { + return false + } + + // Validate Lottie JSON structure: must have v, ip, op, layers + try { + const str = jsonBuffer.toString('utf8', 0, Math.min(jsonBuffer.length, 4096)) + // Quick check for required Lottie fields + return str.includes('"v"') && str.includes('"layers"') && (str.includes('"ip"') || str.includes('"op"')) + } catch { + return false + } +} + /** * Converte uma imagem para WebP usando Sharp * Preserva o buffer original se já for WebP para manter EXIF e animações @@ -114,12 +157,18 @@ export const isAnimatedWebP = (buffer: Buffer): boolean => { const convertToWebP = async ( buffer: Buffer, logger?: ILogger -): Promise<{ webpBuffer: Buffer; isAnimated: boolean }> => { +): Promise<{ webpBuffer: Buffer; isAnimated: boolean; isLottie: boolean }> => { + // Lottie/WAS: passthrough sem converter (formato vetorial, mimetype=application/was) + if (isLottieBuffer(buffer)) { + logger?.trace('Input is Lottie/WAS format, preserving original buffer') + return { webpBuffer: buffer, isAnimated: true, isLottie: true } + } + // Se já é WebP, preserva o buffer original (mantém EXIF e animações) if (isWebPBuffer(buffer)) { const isAnimated = isAnimatedWebP(buffer) logger?.trace({ isAnimated }, 'Input is already WebP, preserving original buffer') - return { webpBuffer: buffer, isAnimated } + return { webpBuffer: buffer, isAnimated, isLottie: false } } // Tenta usar Sharp para converter @@ -135,7 +184,7 @@ const convertToWebP = async ( logger?.trace('Converting image to WebP using Sharp') const webpBuffer = await lib.sharp.default(buffer).webp().toBuffer() - return { webpBuffer, isAnimated: false } + return { webpBuffer, isAnimated: false, isLottie: false } } /** @@ -464,9 +513,33 @@ export const prepareStickerPackMessage = async ( // Obtém buffer do sticker const buffer = await mediaToBuffer(sticker.data, `sticker ${i + 1}`) - // Converte para WebP + // Detecta formato e converte se necessário // eslint-disable-next-line prefer-const - let { webpBuffer, isAnimated } = await convertToWebP(buffer, logger) + let { webpBuffer, isAnimated, isLottie } = await convertToWebP(buffer, logger) + + // Honor explicit isLottie flag from user input + if (sticker.isLottie !== undefined) { + isLottie = sticker.isLottie + } + + // WABA: Enforce 512x512 dimensions for WebP stickers (Lottie is vector, skip) + if (!isLottie && isWebPBuffer(webpBuffer)) { + const lib = await getImageProcessingLibrary() + if (lib?.sharp) { + const metadata = await lib.sharp.default(webpBuffer).metadata() + if (metadata.width !== 512 || metadata.height !== 512) { + logger?.trace( + { index: i, width: metadata.width, height: metadata.height }, + 'Resizing sticker to 512x512 (WABA standard)' + ) + webpBuffer = await lib.sharp + .default(webpBuffer) + .resize(512, 512, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .webp() + .toBuffer() + } + } + } // ENHANCEMENT: Auto-compression if exceeds 1MB (try quality 70, then 50) const MAX_STICKER_SIZE = 1024 * 1024 // 1MB @@ -547,12 +620,13 @@ export const prepareStickerPackMessage = async ( ) } - // Gera nome do arquivo: hash.webp (deduplicação automática) + // Gera nome do arquivo: hash.webp ou hash.was (WABA: plain_file_hash.ext) const sha256Hash = generateSha256Hash(webpBuffer) - const fileName = `${sha256Hash}.webp` + const extension = isLottie ? 'was' : 'webp' + const fileName = `${sha256Hash}.${extension}` logger?.trace( - { index: i, fileName, sizeKB: finalSizeKB.toFixed(2), isAnimated }, + { index: i, fileName, sizeKB: finalSizeKB.toFixed(2), isAnimated, isLottie }, 'Sticker processed successfully' ) @@ -560,6 +634,7 @@ export const prepareStickerPackMessage = async ( fileName, webpBuffer, isAnimated, + isLottie, emojis: sticker.emojis || [], accessibilityLabel: sticker.accessibilityLabel } @@ -578,7 +653,7 @@ export const prepareStickerPackMessage = async ( for (const result of processedStickers) { if (!result) continue - const { fileName, webpBuffer, isAnimated, emojis, accessibilityLabel } = result + const { fileName, webpBuffer, isAnimated, isLottie, emojis, accessibilityLabel } = result // SECURITY FIX #7: Check if this hash already exists (duplicate sticker) const existingMetadata = metadataByHash.get(fileName) @@ -605,13 +680,14 @@ export const prepareStickerPackMessage = async ( // New sticker - add to ZIP and create metadata stickerData[fileName] = [new Uint8Array(webpBuffer), { level: 0 as 0 }] + // WABA: mimetype é 'application/was' para Lottie, 'image/webp' para WebP const metadata: proto.Message.StickerPackMessage.ISticker = { fileName, isAnimated, emojis, accessibilityLabel, - isLottie: false, - mimetype: 'image/webp' + isLottie, + mimetype: isLottie ? 'application/was' : 'image/webp' } metadataByHash.set(fileName, metadata) @@ -636,10 +712,22 @@ export const prepareStickerPackMessage = async ( logger?.trace('Processing cover image') coverBuffer = await mediaToBuffer(cover, 'cover image') - // Converte cover para WebP e adiciona ao ZIP - const result = await convertToWebP(coverBuffer, logger) - coverWebP = result.webpBuffer - coverFileName = `${stickerPackId}.webp` + // WABA: Tray icon uses PNG format (installed_tray_image_id = .png) + const lib = await getImageProcessingLibrary() + if (lib?.sharp) { + // Convert cover to PNG and resize to 96x96 (WABA tray icon standard) + coverWebP = await lib.sharp + .default(coverBuffer) + .resize(96, 96, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .png() + .toBuffer() + } else { + // Fallback: convert to WebP if Sharp not available + const result = await convertToWebP(coverBuffer, logger) + coverWebP = result.webpBuffer + } + + coverFileName = `${stickerPackId}.png` stickerData[coverFileName] = [new Uint8Array(coverWebP), { level: 0 as 0 }] } catch (error) { throw new Boom(`Failed to process cover image: ${(error as Error).message}`, {