Compare commits

..

3 Commits

Author SHA1 Message Date
Renato Alcara 39e8b24eaa chore: bump WhatsApp Web version to v2.3000.1038487394 on Mar/4 base 2026-04-30 19:30:06 -03:00
Renato Alcara 52aa6402c0 fix(signal): align Signal Protocol handling with WABA Android behavior (#257)
7 corrections based on reverse-engineering of WhatsApp Business Android:

1. resolveCanonicalJid: PN→LID resolution for transaction lock keys (prevent race conditions)
2. Retain PN session during LID migration (avoid No Session errors)
3. Delayed PreKey deletion with 5-min grace period (prevent Invalid PreKey ID races)
4. Surgical session cleanup: only delete the specific corrupted device, not all devices
5. Identity dual storage: save identity key in both LID and PN addresses
6. MAC error cooldown reduced 10s→1s (faster recovery, aligned with WABA)
7. Allow session recreation on first retry (retryCount >= 1 instead of > 1)

* fix: remove unused jidDecode import from decode-wa-message.ts
* fix: add try/catch to delayed PreKey deletion to prevent unhandled rejection

The setTimeout async callback in removePreKey could cause unhandled promise
rejection if the keystore was destroyed (connection closed) before the
5-min grace period expired.
2026-03-04 00:51:20 -03:00
github-actions[bot] 8ea7f5434e chore: update proto/version to v2.3000.1034427372 (#258)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-04 00:43:59 -03:00
8 changed files with 110 additions and 185 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
syntax = "proto3"; syntax = "proto3";
package proto; package proto;
/// WhatsApp Version: 2.3000.1034302344 /// WhatsApp Version: 2.3000.1034427372
message ADVDeviceIdentity { message ADVDeviceIdentity {
optional uint32 rawId = 1; optional uint32 rawId = 1;
+1 -1
View File
@@ -1 +1 @@
{"version":[2,3000,1034382497]} {"version":[2,3000,1038487394]}
+79 -13
View File
@@ -302,6 +302,25 @@ export function makeLibSignalRepository(
// Promise instead of each spawning their own DB transactions. // Promise instead of each spawning their own DB transactions.
const migrationInFlight = new Map<string, Promise<{ migrated: number; skipped: number; total: number }>>() const migrationInFlight = new Map<string, Promise<{ migrated: number; skipped: number; total: number }>>()
// Resolve PN JID to its canonical LID JID for transaction locking.
// This prevents PN/LID race conditions where concurrent operations for the
// same logical contact acquire different mutex locks because one uses PN
// and the other uses LID. (Aligned with WABA behavior — all operations use LID internally.)
const resolveCanonicalJid = async(jid: string): Promise<string> => {
if (isAnyLidUser(jid)) {
return jid
}
if (isAnyPnUser(jid)) {
const lid = await lidMapping.getLIDForPN(jid)
if (lid) {
return lid
}
}
return jid
}
const repository: SignalRepositoryWithLIDStore = { const repository: SignalRepositoryWithLIDStore = {
decryptGroupMessage({ group, authorJid, msg }) { decryptGroupMessage({ group, authorJid, msg }) {
const senderName = jidToSignalSenderKeyName(group, authorJid) const senderName = jidToSignalSenderKeyName(group, authorJid)
@@ -396,23 +415,24 @@ export function makeLibSignalRepository(
return result return result
} }
// If it's not a sync message, we need to ensure atomicity // Use canonical JID (PN→LID resolved) as transaction key to prevent
// For regular messages, we use a transaction to ensure atomicity // PN/LID race conditions on the same logical session.
const canonicalJid = await resolveCanonicalJid(jid)
return parsedKeys.transaction(async () => { return parsedKeys.transaction(async () => {
return await doDecrypt() return await doDecrypt()
}, jid) }, canonicalJid)
}, },
async encryptMessage({ jid, data }) { async encryptMessage({ jid, data }) {
const addr = jidToSignalProtocolAddress(jid) const addr = jidToSignalProtocolAddress(jid)
const cipher = new libsignal.SessionCipher(storage, addr) const cipher = new libsignal.SessionCipher(storage, addr)
// Use transaction to ensure atomicity const canonicalJid = await resolveCanonicalJid(jid)
return parsedKeys.transaction(async () => { return parsedKeys.transaction(async () => {
const { type: sigType, body } = await cipher.encrypt(data) const { type: sigType, body } = await cipher.encrypt(data)
const type = sigType === 3 ? 'pkmsg' : 'msg' const type = sigType === 3 ? 'pkmsg' : 'msg'
return { type, ciphertext: Buffer.from(body, 'binary') } return { type, ciphertext: Buffer.from(body, 'binary') }
}, jid) }, canonicalJid)
}, },
async encryptGroupMessage({ group, meId, data }) { async encryptGroupMessage({ group, meId, data }) {
@@ -654,9 +674,10 @@ export function makeLibSignalRepository(
// Session exists (guaranteed from device discovery) // Session exists (guaranteed from device discovery)
const fromSession = libsignal.SessionRecord.deserialize(pnSession) const fromSession = libsignal.SessionRecord.deserialize(pnSession)
if (fromSession.haveOpenSession()) { if (fromSession.haveOpenSession()) {
// Queue for bulk update: copy to LID, delete from PN // Queue for bulk update: copy to LID, retain PN session.
// WABA retains both PN and LID sessions during migration to avoid
// No Session errors if messages arrive via PN before migration completes.
sessionUpdates[lidAddrStr] = fromSession.serialize() sessionUpdates[lidAddrStr] = fromSession.serialize()
sessionUpdates[pnAddrStr] = null
migratedCount++ migratedCount++
} }
@@ -776,6 +797,13 @@ function signalStorage(
return id return id
} }
// Delayed PreKey deletion: grace period to handle race conditions
// where two pkmsg with the same preKeyId arrive nearly simultaneously.
// WABA deletes immediately (33ms), but we add a 5-min grace period
// because we can't handle "Invalid PreKey ID" errors at the native level.
const PREKEY_GRACE_PERIOD_MS = 5 * 60 * 1000 // 5 minutes
const pendingPreKeyDeletions = new Map<string, ReturnType<typeof setTimeout>>()
return { return {
loadSession: async (id: string) => { loadSession: async (id: string) => {
try { try {
@@ -808,7 +836,26 @@ function signalStorage(
} }
} }
}, },
removePreKey: (id: number) => keys.set({ 'pre-key': { [id]: null } }), removePreKey: (id: number) => {
const keyId = id.toString()
// Clear any existing timer for this key
const existing = pendingPreKeyDeletions.get(keyId)
if (existing) {
clearTimeout(existing)
}
// Schedule deletion after grace period
const timer = setTimeout(async () => {
pendingPreKeyDeletions.delete(keyId)
try {
await keys.set({ 'pre-key': { [id]: null } })
} catch {
// Keystore may be destroyed if connection closed — safe to ignore
}
}, PREKEY_GRACE_PERIOD_MS)
pendingPreKeyDeletions.set(keyId, timer)
},
loadSignedPreKey: () => { loadSignedPreKey: () => {
const key = creds.signedPreKey const key = creds.signedPreKey
return { return {
@@ -898,14 +945,24 @@ function signalStorage(
// IDENTITY KEY CHANGED - contact reinstalled WhatsApp or switched devices // IDENTITY KEY CHANGED - contact reinstalled WhatsApp or switched devices
const previousFingerprint = generateKeyFingerprint(existingKey) const previousFingerprint = generateKeyFingerprint(existingKey)
// Delete old session and save new identity key atomically // Delete old session and save new identity key atomically.
// Store identity in BOTH LID and PN addresses (WABA stores in both
// recipient_account_type=0 and type=1 with CONFLICT_REPLACE).
const identityUpdates: Record<string, Uint8Array> = { [wireJid]: identityKey }
if (wireJid !== id) {
identityUpdates[id] = identityKey
}
await keys.set({ await keys.set({
session: { [wireJid]: null }, session: { [wireJid]: null },
'identity-key': { [wireJid]: identityKey } 'identity-key': identityUpdates
}) })
// Update cache // Update cache for both addresses
identityKeyCache.set(wireJid, identityKey) identityKeyCache.set(wireJid, identityKey)
if (wireJid !== id) {
identityKeyCache.set(id, identityKey)
}
// Record metrics // Record metrics
metrics.signalIdentityChanges?.inc({ type: 'changed' }) metrics.signalIdentityChanges?.inc({ type: 'changed' })
@@ -941,10 +998,19 @@ function signalStorage(
if (!existingKey) { if (!existingKey) {
// NEW CONTACT - Trust On First Use (TOFU) // NEW CONTACT - Trust On First Use (TOFU)
await keys.set({ 'identity-key': { [wireJid]: identityKey } }) // Store in both LID and PN addresses (aligned with WABA dual identity storage)
const identityUpdates: Record<string, Uint8Array> = { [wireJid]: identityKey }
if (wireJid !== id) {
identityUpdates[id] = identityKey
}
// Update cache await keys.set({ 'identity-key': identityUpdates })
// Update cache for both addresses
identityKeyCache.set(wireJid, identityKey) identityKeyCache.set(wireJid, identityKey)
if (wireJid !== id) {
identityKeyCache.set(id, identityKey)
}
// Record metrics // Record metrics
metrics.signalIdentityChanges?.inc({ type: 'new' }) metrics.signalIdentityChanges?.inc({ type: 'new' })
+2 -2
View File
@@ -1314,7 +1314,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
let shouldRecreateSession = false let shouldRecreateSession = false
let recreateReason = '' let recreateReason = ''
if (enableAutoSessionRecreation && messageRetryManager && retryCount > 1) { if (enableAutoSessionRecreation && messageRetryManager && retryCount >= 1) {
try { try {
// Check if we have a session with this JID // Check if we have a session with this JID
const sessionId = signalRepository.jidToSignalProtocolAddress(fromJid) const sessionId = signalRepository.jidToSignalProtocolAddress(fromJid)
@@ -2033,7 +2033,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
let shouldRecreateSession = false let shouldRecreateSession = false
let recreateReason = '' let recreateReason = ''
if (enableAutoSessionRecreation && messageRetryManager && retryCount > 1) { if (enableAutoSessionRecreation && messageRetryManager && retryCount >= 1) {
try { try {
const sessionId = signalRepository.jidToSignalProtocolAddress(participant) const sessionId = signalRepository.jidToSignalProtocolAddress(participant)
+1 -3
View File
@@ -54,14 +54,12 @@ export type WAMediaUpload = Buffer | WAMediaPayloadStream | WAMediaPayloadURL
* Individual sticker in a sticker pack * Individual sticker in a sticker pack
*/ */
export type Sticker = { export type Sticker = {
/** Buffer, Stream or URL of the sticker image (WebP or Lottie/WAS format) */ /** Buffer, Stream or URL of the sticker image (will be converted to WebP if needed) */
data: WAMediaUpload data: WAMediaUpload
/** Array of emojis associated with this sticker (max 3 recommended per WhatsApp standards) */ /** Array of emojis associated with this sticker (max 3 recommended per WhatsApp standards) */
emojis?: string[] emojis?: string[]
/** Accessibility label for screen readers (max 125 chars for static, 255 for animated) */ /** Accessibility label for screen readers (max 125 chars for static, 255 for animated) */
accessibilityLabel?: string accessibilityLabel?: string
/** Force Lottie format detection (auto-detected if omitted) */
isLottie?: boolean
} }
/** /**
+6 -44
View File
@@ -14,7 +14,6 @@ import {
isJidStatusBroadcast, isJidStatusBroadcast,
isLidUser, isLidUser,
isPnUser, isPnUser,
jidDecode
// transferDevice // transferDevice
} from '../WABinary' } from '../WABinary'
import { unpadRandomMax16 } from './generics' import { unpadRandomMax16 } from './generics'
@@ -488,8 +487,9 @@ export function isCorruptedSessionError(error: any): boolean {
} }
/** /**
* Clean up corrupted session by deleting all device sessions for a JID. * Clean up corrupted session for a specific device JID.
* Signal Protocol will automatically recreate the session on next message. * WABA behavior: DELETE sessions WHERE recipient_id=? AND device_id=?
* Only deletes the exact device that was corrupted, not all devices.
* *
* NOTE: This should NOT be called on every Bad MAC error (hot path). * NOTE: This should NOT be called on every Bad MAC error (hot path).
* Instead, let the retry+pkmsg flow handle recovery naturally (like WhatsApp does). * Instead, let the retry+pkmsg flow handle recovery naturally (like WhatsApp does).
@@ -500,45 +500,7 @@ export async function cleanupCorruptedSession(
repository: SignalRepositoryWithLIDStore, repository: SignalRepositoryWithLIDStore,
logger: ILogger logger: ILogger
): Promise<number> { ): Promise<number> {
const { user, device } = jidDecode(jid) || {} await repository.deleteSession([jid])
if (!user) { logger.info({ jid }, 'Cleaned up corrupted session for specific device')
logger.warn({ jid }, 'Cannot cleanup session - invalid JID') return 1
return 0
}
// Build list of JIDs to delete (primary + secondary devices)
const jidsToDelete: string[] = []
// Determine domain type correctly (handle hosted JIDs)
// JID formats:
// - PN: user@s.whatsapp.net
// - LID: user@lid
// - Hosted PN: user@hosted
// - Hosted LID: user@hosted.lid
const isLID = jid.endsWith('@lid') || jid.endsWith('@hosted.lid')
const isHosted = jid.includes('@hosted')
let domain: string
if (isLID) {
domain = isHosted ? 'hosted.lid' : 'lid'
} else {
domain = isHosted ? 'hosted' : 's.whatsapp.net'
}
// Primary device (0)
jidsToDelete.push(`${user}@${domain}`)
// Secondary devices (1-5 common range for Web/Desktop/etc)
for (let i = 1; i <= 5; i++) {
jidsToDelete.push(`${user}:${i}@${domain}`)
}
// If specific device was identified and > 5, ensure it's included
if (device !== undefined && device > 5) {
jidsToDelete.push(`${user}:${device}@${domain}`)
}
await repository.deleteSession(jidsToDelete)
return jidsToDelete.length
} }
+1 -1
View File
@@ -218,7 +218,7 @@ export class MessageRetryManager {
if (errorCode !== undefined && MAC_ERROR_CODES.has(errorCode)) { if (errorCode !== undefined && MAC_ERROR_CODES.has(errorCode)) {
const now = Date.now() const now = Date.now()
const prevTime = this.sessionRecreateHistory.get(jid) const prevTime = this.sessionRecreateHistory.get(jid)
const MAC_ERROR_COOLDOWN_MS = 10_000 // 10 seconds const MAC_ERROR_COOLDOWN_MS = 1_000 // 1 second — WABA recovers faster
if (prevTime && now - prevTime < MAC_ERROR_COOLDOWN_MS) { if (prevTime && now - prevTime < MAC_ERROR_COOLDOWN_MS) {
const reasonName = RetryReason[errorCode] || `code_${errorCode}` const reasonName = RetryReason[errorCode] || `code_${errorCode}`
+19 -120
View File
@@ -2,7 +2,6 @@ import { Boom } from '@hapi/boom'
import { createHash } from 'crypto' import { createHash } from 'crypto'
import { zipSync } from 'fflate' import { zipSync } from 'fflate'
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
import { gzipSync, gunzipSync } from 'zlib'
import { proto } from '../../WAProto/index.js' import { proto } from '../../WAProto/index.js'
import type { MediaType } from '../Defaults/index.js' import type { MediaType } from '../Defaults/index.js'
import type { StickerPack, WAMediaUpload, WAMediaUploadFunction } from '../Types/Message.js' import type { StickerPack, WAMediaUpload, WAMediaUploadFunction } from '../Types/Message.js'
@@ -102,49 +101,6 @@ export const isAnimatedWebP = (buffer: Buffer): boolean => {
return false 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 {
// SECURITY: Limit decompressed output to 50MB to prevent decompression bombs
jsonBuffer = gunzipSync(buffer, { maxOutputLength: 50 * 1024 * 1024 }) 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, AND layers
try {
const str = jsonBuffer.toString('utf8', 0, Math.min(jsonBuffer.length, 4096))
// Quick check for ALL 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 * Converte uma imagem para WebP usando Sharp
* Preserva o buffer original se for WebP para manter EXIF e animações * Preserva o buffer original se for WebP para manter EXIF e animações
@@ -158,25 +114,12 @@ export const isLottieBuffer = (buffer: Buffer): boolean => {
const convertToWebP = async ( const convertToWebP = async (
buffer: Buffer, buffer: Buffer,
logger?: ILogger logger?: ILogger
): Promise<{ webpBuffer: Buffer; isAnimated: boolean; isLottie: boolean }> => { ): Promise<{ webpBuffer: Buffer; isAnimated: boolean }> => {
// Lottie/WAS: ensure gzip-compressed format (WAS = gzip Lottie JSON)
if (isLottieBuffer(buffer)) {
let wasBuffer = buffer
// Raw Lottie JSON (starts with '{') must be gzipped to produce valid WAS
if (buffer[0] === 0x7b) {
logger?.trace('Raw Lottie JSON detected, gzip-compressing to WAS format')
wasBuffer = gzipSync(buffer) as Buffer
}
logger?.trace('Input is Lottie/WAS format')
return { webpBuffer: wasBuffer, isAnimated: true, isLottie: true }
}
// Se já é WebP, preserva o buffer original (mantém EXIF e animações) // Se já é WebP, preserva o buffer original (mantém EXIF e animações)
if (isWebPBuffer(buffer)) { if (isWebPBuffer(buffer)) {
const isAnimated = isAnimatedWebP(buffer) const isAnimated = isAnimatedWebP(buffer)
logger?.trace({ isAnimated }, 'Input is already WebP, preserving original buffer') logger?.trace({ isAnimated }, 'Input is already WebP, preserving original buffer')
return { webpBuffer: buffer, isAnimated, isLottie: false } return { webpBuffer: buffer, isAnimated }
} }
// Tenta usar Sharp para converter // Tenta usar Sharp para converter
@@ -192,7 +135,7 @@ const convertToWebP = async (
logger?.trace('Converting image to WebP using Sharp') logger?.trace('Converting image to WebP using Sharp')
const webpBuffer = await lib.sharp.default(buffer).webp().toBuffer() const webpBuffer = await lib.sharp.default(buffer).webp().toBuffer()
return { webpBuffer, isAnimated: false, isLottie: false } return { webpBuffer, isAnimated: false }
} }
/** /**
@@ -395,11 +338,9 @@ export type PrepareStickerPackMessageOptions = {
* *
* **Especificações WhatsApp:** * **Especificações WhatsApp:**
* - 3-30 stickers por pack (oficial) * - 3-30 stickers por pack (oficial)
* - WebP ou Lottie/WAS (application/was) * - WebP obrigatório
* - Stickers: 512x512 pixels (auto-resize)
* - Recomendado: 100KB por sticker estático, 500KB animado * - Recomendado: 100KB por sticker estático, 500KB animado
* - Tray icon: 96x96 pixels (PNG no ZIP) * - Tray icon: 252x252 pixels
* - Thumbnail: 252x252 pixels (JPEG, upload separado)
* *
* @param stickerPack - Sticker pack data with stickers, cover, name, publisher * @param stickerPack - Sticker pack data with stickers, cover, name, publisher
* @param options - Upload function and optional logger * @param options - Upload function and optional logger
@@ -523,36 +464,9 @@ export const prepareStickerPackMessage = async (
// Obtém buffer do sticker // Obtém buffer do sticker
const buffer = await mediaToBuffer(sticker.data, `sticker ${i + 1}`) const buffer = await mediaToBuffer(sticker.data, `sticker ${i + 1}`)
// Detecta formato e converte se necessário // Converte para WebP
// eslint-disable-next-line prefer-const // eslint-disable-next-line prefer-const
let { webpBuffer, isAnimated, isLottie } = await convertToWebP(buffer, logger) let { webpBuffer, isAnimated } = await convertToWebP(buffer, logger)
// Validate explicit isLottie flag — must match detected format
if (sticker.isLottie !== undefined && sticker.isLottie !== isLottie) {
throw new Boom(
`Sticker ${i + 1}: explicit isLottie=${sticker.isLottie} does not match detected format (detected=${isLottie})`,
{ statusCode: 400 }
)
}
// 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) // ENHANCEMENT: Auto-compression if exceeds 1MB (try quality 70, then 50)
const MAX_STICKER_SIZE = 1024 * 1024 // 1MB const MAX_STICKER_SIZE = 1024 * 1024 // 1MB
@@ -569,7 +483,7 @@ export const prepareStickerPackMessage = async (
if (lib?.sharp) { if (lib?.sharp) {
// Try quality 70 // Try quality 70
try { try {
const compressed70 = await lib.sharp.default(webpBuffer).webp({ quality: 70 }).toBuffer() const compressed70 = await lib.sharp.default(buffer).webp({ quality: 70 }).toBuffer()
// eslint-disable-next-line max-depth // eslint-disable-next-line max-depth
if (compressed70.length <= MAX_STICKER_SIZE) { if (compressed70.length <= MAX_STICKER_SIZE) {
@@ -584,7 +498,7 @@ export const prepareStickerPackMessage = async (
) )
} else { } else {
// Try quality 50 // Try quality 50
const compressed50 = await lib.sharp.default(webpBuffer).webp({ quality: 50 }).toBuffer() const compressed50 = await lib.sharp.default(buffer).webp({ quality: 50 }).toBuffer()
// eslint-disable-next-line max-depth // eslint-disable-next-line max-depth
if (compressed50.length <= MAX_STICKER_SIZE) { if (compressed50.length <= MAX_STICKER_SIZE) {
@@ -633,13 +547,12 @@ export const prepareStickerPackMessage = async (
) )
} }
// Gera nome do arquivo: hash.webp ou hash.was (WABA: plain_file_hash.ext) // Gera nome do arquivo: hash.webp (deduplicação automática)
const sha256Hash = generateSha256Hash(webpBuffer) const sha256Hash = generateSha256Hash(webpBuffer)
const extension = isLottie ? 'was' : 'webp' const fileName = `${sha256Hash}.webp`
const fileName = `${sha256Hash}.${extension}`
logger?.trace( logger?.trace(
{ index: i, fileName, sizeKB: finalSizeKB.toFixed(2), isAnimated, isLottie }, { index: i, fileName, sizeKB: finalSizeKB.toFixed(2), isAnimated },
'Sticker processed successfully' 'Sticker processed successfully'
) )
@@ -647,7 +560,6 @@ export const prepareStickerPackMessage = async (
fileName, fileName,
webpBuffer, webpBuffer,
isAnimated, isAnimated,
isLottie,
emojis: sticker.emojis || [], emojis: sticker.emojis || [],
accessibilityLabel: sticker.accessibilityLabel accessibilityLabel: sticker.accessibilityLabel
} }
@@ -666,7 +578,7 @@ export const prepareStickerPackMessage = async (
for (const result of processedStickers) { for (const result of processedStickers) {
if (!result) continue if (!result) continue
const { fileName, webpBuffer, isAnimated, isLottie, emojis, accessibilityLabel } = result const { fileName, webpBuffer, isAnimated, emojis, accessibilityLabel } = result
// SECURITY FIX #7: Check if this hash already exists (duplicate sticker) // SECURITY FIX #7: Check if this hash already exists (duplicate sticker)
const existingMetadata = metadataByHash.get(fileName) const existingMetadata = metadataByHash.get(fileName)
@@ -693,14 +605,13 @@ export const prepareStickerPackMessage = async (
// New sticker - add to ZIP and create metadata // New sticker - add to ZIP and create metadata
stickerData[fileName] = [new Uint8Array(webpBuffer), { level: 0 as 0 }] 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 = { const metadata: proto.Message.StickerPackMessage.ISticker = {
fileName, fileName,
isAnimated, isAnimated,
emojis, emojis,
accessibilityLabel, accessibilityLabel,
isLottie, isLottie: false,
mimetype: isLottie ? 'application/was' : 'image/webp' mimetype: 'image/webp'
} }
metadataByHash.set(fileName, metadata) metadataByHash.set(fileName, metadata)
@@ -725,22 +636,10 @@ export const prepareStickerPackMessage = async (
logger?.trace('Processing cover image') logger?.trace('Processing cover image')
coverBuffer = await mediaToBuffer(cover, 'cover image') coverBuffer = await mediaToBuffer(cover, 'cover image')
// Tray icon uses PNG format, 96x96 pixels (official client standard) // Converte cover para WebP e adiciona ao ZIP
const lib = await getImageProcessingLibrary() const result = await convertToWebP(coverBuffer, logger)
if (!lib?.sharp) { coverWebP = result.webpBuffer
throw new Boom( coverFileName = `${stickerPackId}.webp`
'Sharp library is required for cover/tray icon processing. Install with: yarn add sharp',
{ statusCode: 400 }
)
}
coverWebP = await lib.sharp
.default(coverBuffer)
.resize(96, 96, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
.png()
.toBuffer()
coverFileName = `${stickerPackId}.png`
stickerData[coverFileName] = [new Uint8Array(coverWebP), { level: 0 as 0 }] stickerData[coverFileName] = [new Uint8Array(coverWebP), { level: 0 as 0 }]
} catch (error) { } catch (error) {
throw new Boom(`Failed to process cover image: ${(error as Error).message}`, { throw new Boom(`Failed to process cover image: ${(error as Error).message}`, {