diff --git a/package.json b/package.json index 748363e6..7e345630 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@cacheable/node-cache": "^1.4.0", "@hapi/boom": "^9.1.3", "async-mutex": "^0.5.0", + "fflate": "^0.8.2", "libsignal": "git+https://github.com/whiskeysockets/libsignal-node", "lru-cache": "^11.1.0", "music-metadata": "^11.7.0", diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index e5cc38fc..b40e74d7 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -123,7 +123,9 @@ export const MEDIA_PATH_MAP: { [T in MediaType]?: string } = { 'product-catalog-image': '/product/image', 'md-app-state': '', 'md-msg-hist': '/mms/md-app-state', - 'biz-cover-photo': '/pps/biz-cover-photo' + 'biz-cover-photo': '/pps/biz-cover-photo', + 'sticker-pack': '/mms/sticker-pack', + 'thumbnail-sticker-pack': '/mms/thumbnail-sticker-pack' } export const MEDIA_HKDF_KEY_MAPPING = { @@ -145,7 +147,9 @@ export const MEDIA_HKDF_KEY_MAPPING = { 'product-catalog-image': '', 'payment-bg-image': 'Payment Background', ptv: 'Video', - 'biz-cover-photo': 'Image' + 'biz-cover-photo': 'Image', + 'sticker-pack': 'Sticker Pack', + 'thumbnail-sticker-pack': 'Sticker Pack Thumbnail' } export type MediaType = keyof typeof MEDIA_HKDF_KEY_MAPPING diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index fd8e3c59..fbf283f2 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -1374,6 +1374,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { : message.audioMessage ? 'audio' : message.documentMessage ? 'document' : message.stickerMessage ? 'sticker' + : message.stickerPackMessage ? 'sticker_pack' : message.reactionMessage ? 'reaction' : 'other' recordMessageSent(msgType) diff --git a/src/Types/Message.ts b/src/Types/Message.ts index 539a4b5c..6834dcbe 100644 --- a/src/Types/Message.ts +++ b/src/Types/Message.ts @@ -39,6 +39,59 @@ import type { ILogger } from '../Utils/logger' export type WAMediaPayloadURL = { url: URL | string } export type WAMediaPayloadStream = { stream: Readable } 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) */ + 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 +} + +/** + * Sticker Pack message - send a complete pack of stickers + * + * Follows WhatsApp official specifications: + * - 3-30 stickers per pack (enforced) + * - WebP format (auto-converted) + * - Max 100KB per static sticker, 500KB per animated (recommended, not enforced) + * - Either all static OR all animated (recommended) + * + * @example + * ```typescript + * await sock.sendMessage(jid, { + * stickerPack: { + * name: 'My Awesome Pack', + * publisher: 'Your Name', + * description: 'Cool stickers collection', + * cover: Buffer.from(...), // or URL or Stream + * stickers: [ + * { data: Buffer.from(...), emojis: ['😀', '😃'] }, + * { data: 'https://example.com/sticker.webp', emojis: ['😎'] } + * ] + * } + * }) + * ``` + */ +export type StickerPack = { + /** Array of stickers (minimum 3, maximum 30 per WhatsApp official spec) */ + stickers: Sticker[] + /** Cover/tray icon for the pack (will be auto-resized to 252x252 JPEG) */ + cover: WAMediaUpload + /** Pack name (max 128 characters) */ + name: string + /** Publisher/author name (max 128 characters) */ + publisher: string + /** Optional pack description */ + description?: string + /** Optional custom pack ID (auto-generated if omitted) */ + packId?: string +} + /** Set of message types that are supported by the library */ export type MessageType = keyof proto.Message @@ -818,6 +871,44 @@ export type AnyRegularMessageContent = ( */ album: AlbumMessageOptions } + | { + /** + * Sticker Pack - Send a complete pack of stickers (3-30 stickers) + * + * The pack will appear in the recipient's sticker tray, similar to official sticker packs. + * All stickers are automatically converted to WebP format if needed. + * + * @example + * ```typescript + * import { readFileSync } from 'fs' + * + * await sock.sendMessage(jid, { + * stickerPack: { + * name: 'Emoji Pack', + * publisher: 'InfiniteAPI', + * description: 'Fun emoji stickers', + * cover: readFileSync('./pack-cover.png'), + * stickers: [ + * { data: readFileSync('./sticker1.webp'), emojis: ['😀'] }, + * { data: readFileSync('./sticker2.png'), emojis: ['😎', '🔥'] }, + * { data: { url: 'https://example.com/sticker3.webp' }, emojis: ['🎉'] } + * ] + * } + * }) + * ``` + * + * **Requirements:** + * - `fflate` package (installed automatically) + * - `sharp` package for image processing: `yarn add sharp` + * + * **Specifications (WhatsApp Official):** + * - Minimum 3 stickers, maximum 30 per pack + * - Recommended: 100KB per static sticker, 500KB per animated + * - WebP format (auto-converted from PNG/JPG/etc) + * - Best practice: All stickers either static OR animated, not mixed + */ + stickerPack: StickerPack + } | SharePhoneNumber | RequestPhoneNumber ) & diff --git a/src/Utils/messages-media.ts b/src/Utils/messages-media.ts index d2cbe7d3..7c691163 100644 --- a/src/Utils/messages-media.ts +++ b/src/Utils/messages-media.ts @@ -31,7 +31,12 @@ import type { ILogger } from './logger' const getTmpFilesDirectory = () => tmpdir() -const getImageProcessingLibrary = async () => { +/** + * Get available image processing library (Sharp or Jimp) + * Exported for use in sticker pack processing + * @returns Object with sharp or jimp property, or throws if neither available + */ +export const getImageProcessingLibrary = async () => { //@ts-ignore const [jimp, sharp] = await Promise.all([import('jimp').catch(() => {}), import('sharp').catch(() => {})]) @@ -380,18 +385,21 @@ type EncryptedStreamOptions = { saveOriginalFileIfRequired?: boolean logger?: ILogger opts?: RequestInit + /** Optional mediaKey to reuse (required for sticker pack thumbnail to match ZIP encryption) */ + mediaKey?: Uint8Array } export const encryptedStream = async ( media: WAMediaUpload, mediaType: MediaType, - { logger, saveOriginalFileIfRequired, opts }: EncryptedStreamOptions = {} + { logger, saveOriginalFileIfRequired, opts, mediaKey: providedMediaKey }: EncryptedStreamOptions = {} ) => { const { stream, type } = await getStream(media, opts) logger?.debug('fetched media stream') - const mediaKey = Crypto.randomBytes(32) + // Use provided mediaKey or generate new one + const mediaKey = providedMediaKey || Crypto.randomBytes(32) const { cipherKey, iv, macKey } = await getMediaKeys(mediaKey, mediaType) const encFilePath = join(getTmpFilesDirectory(), mediaType + generateMessageIDV2() + '-enc') diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index c3ddf38a..46c6cad4 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -47,6 +47,7 @@ import { getRawMediaUploadData, type MediaDownloadOptions } from './messages-media' +import { prepareStickerPackMessage } from './sticker-pack.js' import { shouldIncludeReportingToken } from './reporting-utils' type ExtractByKey = T extends Record ? T : never @@ -1493,6 +1494,16 @@ export const generateWAMessageContent = async ( } } else if (hasNonNullishProperty(message, 'requestPhoneNumber')) { m.requestPhoneNumberMessage = {} + } else if (hasNonNullishProperty(message, 'stickerPack')) { + // Prepare sticker pack message (3-30 stickers per WhatsApp official spec) + options.logger?.info('Generating sticker pack message') + + const stickerPackMessage = await prepareStickerPackMessage(message.stickerPack, { + upload: options.upload, + logger: options.logger + }) + + m.stickerPackMessage = stickerPackMessage } else if (hasNonNullishProperty(message, 'limitSharing')) { m.protocolMessage = { type: proto.Message.ProtocolMessage.Type.LIMIT_SHARING, diff --git a/src/Utils/sticker-pack.ts b/src/Utils/sticker-pack.ts new file mode 100644 index 00000000..77d71337 --- /dev/null +++ b/src/Utils/sticker-pack.ts @@ -0,0 +1,440 @@ +import { createHash } from 'crypto' +import { zipSync } from 'fflate' +import { promises as fs } from 'fs' +import { Boom } from '@hapi/boom' +import { proto } from '../../WAProto/index.js' +import type { MediaType } from '../Defaults/index.js' +import type { Sticker, StickerPack, WAMediaUpload, WAMediaUploadFunction } from '../Types/Message.js' +import type { ILogger } from './logger.js' +import { generateMessageIDV2 } from './generics.js' +import { getImageProcessingLibrary, encryptedStream } from './messages-media.js' + +/** + * Verifica se um buffer é um arquivo WebP válido + * Valida os magic bytes: RIFF....WEBP + * + * @param buffer - Buffer to check + * @returns true if buffer is valid WebP format + * + * @example + * ```typescript + * const buffer = await readFile('image.webp') + * if (isWebPBuffer(buffer)) { + * console.log('Valid WebP file') + * } + * ``` + */ +export const isWebPBuffer = (buffer: Buffer): boolean => { + if (buffer.length < 12) return false + + // Verifica magic bytes RIFF (0-3) e WEBP (8-11) + const riffHeader = buffer.toString('ascii', 0, 4) + const webpHeader = buffer.toString('ascii', 8, 12) + + return riffHeader === 'RIFF' && webpHeader === 'WEBP' +} + +/** + * Detecta se um WebP é animado através da análise de chunks + * + * Analisa a estrutura do arquivo WebP procurando por: + * - VP8X header com animation flag (bit 1) + * - Chunks ANIM (animation) ou ANMF (animation frame) + * + * @param buffer - WebP buffer to analyze + * @returns true if WebP is animated + * + * @example + * ```typescript + * const webpBuffer = await readFile('sticker.webp') + * if (isAnimatedWebP(webpBuffer)) { + * console.log('Animated sticker detected') + * } + * ``` + */ +export const isAnimatedWebP = (buffer: Buffer): boolean => { + if (!isWebPBuffer(buffer)) return false + + let offset = 12 // Skip RIFF header (12 bytes) + + while (offset < buffer.length - 8) { + const chunkFourCC = buffer.toString('ascii', offset, offset + 4) + const chunkSize = buffer.readUInt32LE(offset + 4) + + // VP8X extended header - check animation flag + if (chunkFourCC === 'VP8X' && offset + 8 < buffer.length) { + const flags = buffer[offset + 8] + // Bit 1 (0x02) = animation flag + if (flags && (flags & 0x02)) return true + } + + // Animation chunks + if (chunkFourCC === 'ANIM' || chunkFourCC === 'ANMF') { + return true + } + + // Move to next chunk (8 byte header + chunk size + padding) + offset += 8 + chunkSize + (chunkSize % 2) + } + + return false +} + +/** + * Converte uma imagem para WebP usando Sharp + * Preserva o buffer original se já for WebP para manter EXIF e animações + * + * @param buffer - Image buffer to convert + * @param logger - Optional logger for debugging + * @returns Object with WebP buffer and animation status + * + * @throws {Boom} If Sharp is not installed and buffer is not WebP + */ +const convertToWebP = async ( + buffer: Buffer, + logger?: ILogger +): Promise<{ webpBuffer: Buffer; isAnimated: boolean }> => { + // 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 } + } + + // Tenta usar Sharp para converter + const lib = await getImageProcessingLibrary() + + if (!lib?.sharp) { + throw new Boom( + 'Sharp library is required to convert non-WebP images to WebP format. Install with: yarn add sharp', + { statusCode: 400 } + ) + } + + logger?.trace('Converting image to WebP using Sharp') + const webpBuffer = await lib.sharp(buffer).webp().toBuffer() + + return { webpBuffer, isAnimated: false } +} + +/** + * Gera hash SHA256 em formato base64 URL-safe + * Usado para nomear arquivos de stickers no ZIP (auto-deduplicação) + * + * @param buffer - Buffer to hash + * @returns Base64 URL-safe SHA256 hash + */ +const generateSha256Hash = (buffer: Buffer): string => { + return createHash('sha256') + .update(buffer) + .digest('base64') + .replace(/\//g, '-') // Base64 URL-safe + .replace(/\+/g, '-') + .replace(/=/g, '') +} + +/** + * Converte WAMediaUpload para Buffer + * Suporta Buffer, Stream e URL + * + * @param media - Media input (Buffer, Stream or URL) + * @param context - Context for error messages (e.g., 'sticker', 'cover') + * @returns Buffer with media content + * + * @throws {Boom} If media format is invalid + */ +const mediaToBuffer = async (media: WAMediaUpload, context: string): Promise => { + if (Buffer.isBuffer(media)) { + return media + } else if (typeof media === 'object' && 'stream' in media) { + // Read stream to buffer + const chunks: Buffer[] = [] + for await (const chunk of media.stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + return Buffer.concat(chunks) + } else if (typeof media === 'object' && 'url' in media) { + // Download from URL + const url = media.url.toString() + const response = await fetch(url) + + if (!response.ok) { + throw new Boom(`Failed to download ${context} from URL: ${url}`, { + statusCode: 400, + data: { url, status: response.status } + }) + } + + return Buffer.from(await response.arrayBuffer()) + } else { + throw new Boom(`Invalid ${context} data format`, { statusCode: 400 }) + } +} + +export type PrepareStickerPackMessageOptions = { + /** Upload function to encrypt and upload media to WhatsApp servers */ + upload: WAMediaUploadFunction + /** Optional logger for debugging */ + logger?: ILogger + /** Timeout for media uploads */ + mediaUploadTimeoutMs?: number +} + +/** + * Prepara uma mensagem de sticker pack para envio + * + * **Processo:** + * 1. Valida número de stickers (3-30 conforme padrão WhatsApp oficial) + * 2. Processa cada sticker (converte para WebP se necessário) + * 3. Cria ZIP com stickers + cover (deduplicação automática por hash) + * 4. Criptografa ZIP usando AES-256-CBC + HMAC-SHA256 + * 5. Gera thumbnail da capa (252x252 JPEG) + * 6. Faz upload do ZIP e thumbnail (reutiliza mesma mediaKey) + * 7. Retorna proto.Message.StickerPackMessage completo + * + * **Especificações WhatsApp:** + * - 3-30 stickers por pack (oficial) + * - WebP obrigatório + * - Recomendado: 100KB por sticker estático, 500KB animado + * - Tray icon: 252x252 pixels + * + * @param stickerPack - Sticker pack data with stickers, cover, name, publisher + * @param options - Upload function and optional logger + * @returns Prepared StickerPackMessage ready to send + * + * @throws {Boom} If validation fails (sticker count, size limits, format issues) + * + * @example + * ```typescript + * const stickerPackMessage = await prepareStickerPackMessage( + * { + * name: 'My Pack', + * publisher: 'Author', + * cover: coverBuffer, + * stickers: [ + * { data: sticker1Buffer, emojis: ['😀'] }, + * { data: sticker2Buffer, emojis: ['😎'] } + * ] + * }, + * { upload: uploadFunction, logger } + * ) + * ``` + */ +export const prepareStickerPackMessage = async ( + stickerPack: StickerPack, + options: PrepareStickerPackMessageOptions +): Promise => { + const { upload, logger, mediaUploadTimeoutMs } = options + const { stickers, cover, name, publisher, description, packId } = stickerPack + + // Helper function to encrypt and upload media + const uploadMedia = async (buffer: Buffer, mediaType: MediaType, opts?: { mediaKey?: Uint8Array }) => { + // Encrypt the media + const encrypted = await encryptedStream(buffer, mediaType, { + logger, + mediaKey: opts?.mediaKey + }) + + // Upload encrypted file + const result = await upload(encrypted.encFilePath, { + fileEncSha256B64: encrypted.fileEncSha256.toString('base64'), + mediaType, + timeoutMs: mediaUploadTimeoutMs + }) + + // Clean up encrypted file + await fs.unlink(encrypted.encFilePath) + + return { + mediaKey: encrypted.mediaKey, + fileSha256: encrypted.fileSha256, + fileEncSha256: encrypted.fileEncSha256, + directPath: result.directPath, + mediaKeyTimestamp: result.ts + } + } + + // 1. Validações - Padrão WhatsApp oficial: 3-30 stickers + if (stickers.length < 3 || stickers.length > 30) { + throw new Boom( + `Sticker pack must contain between 3 and 30 stickers per WhatsApp official spec. Provided: ${stickers.length}`, + { statusCode: 400 } + ) + } + + // Validação de nomes (max 128 caracteres) + if (name.length > 128) { + throw new Boom(`Pack name must be 128 characters or less. Current length: ${name.length}`, { + statusCode: 400 + }) + } + + if (publisher.length > 128) { + throw new Boom(`Publisher name must be 128 characters or less. Current length: ${publisher.length}`, { + statusCode: 400 + }) + } + + logger?.info({ stickerCount: stickers.length, name, publisher }, 'Preparing sticker pack message') + + // 2. Gera ID do pack se não fornecido + const stickerPackId = packId || generateMessageIDV2() + + // 3. Processa stickers e cria estrutura ZIP + const stickerData: Record = {} + const stickerMetadata: proto.Message.StickerPackMessage.ISticker[] = [] + + for (let i = 0; i < stickers.length; i++) { + const sticker = stickers[i] + if (!sticker) continue // Skip undefined stickers + + logger?.trace({ index: i }, 'Processing sticker') + + // Obtém buffer do sticker + const buffer = await mediaToBuffer(sticker.data, `sticker ${i + 1}`) + + // Converte para WebP + const { webpBuffer, isAnimated } = await convertToWebP(buffer, logger) + + // Validação de tamanho (1MB hard limit, mas avisa se exceder recomendado) + const sizeKB = webpBuffer.length / 1024 + const recommendedLimit = isAnimated ? 500 : 100 + + if (webpBuffer.length > 1024 * 1024) { + throw new Boom( + `Sticker ${i + 1} exceeds the 1MB hard limit (${sizeKB.toFixed(2)}KB). ` + + `Please compress or reduce quality.`, + { statusCode: 400 } + ) + } + + if (sizeKB > recommendedLimit) { + logger?.warn( + { index: i, sizeKB, recommendedLimit, isAnimated }, + `Sticker ${i + 1} exceeds WhatsApp recommended size (${recommendedLimit}KB). ` + + `This may cause slower sending or delivery issues.` + ) + } + + // Gera nome do arquivo: hash.webp (deduplicação automática) + const sha256Hash = generateSha256Hash(webpBuffer) + const fileName = `${sha256Hash}.webp` + + // Adiciona ao ZIP (se já existe, sobrescreve - deduplicação) + stickerData[fileName] = [new Uint8Array(webpBuffer), { level: 0 as 0 }] + + // Metadata do sticker + stickerMetadata.push({ + fileName, + isAnimated, + emojis: sticker.emojis || [], + accessibilityLabel: sticker.accessibilityLabel, + isLottie: false, + mimetype: 'image/webp' + }) + + logger?.trace( + { index: i, fileName, sizeKB: sizeKB.toFixed(2), isAnimated }, + 'Sticker processed successfully' + ) + } + + // 4. Processa cover image (tray icon) + logger?.trace('Processing cover image') + const coverBuffer = await mediaToBuffer(cover, 'cover image') + + // Converte cover para WebP e adiciona ao ZIP + const { webpBuffer: coverWebP } = await convertToWebP(coverBuffer, logger) + const coverFileName = `${stickerPackId}.webp` + stickerData[coverFileName] = [new Uint8Array(coverWebP), { level: 0 as 0 }] + + // 5. Cria ZIP (level 0 = sem compressão para velocidade) + const uniqueFiles = Object.keys(stickerData).length + logger?.trace({ totalFiles: uniqueFiles, includingCover: true }, 'Creating ZIP file') + + const zipBuffer = Buffer.from(zipSync(stickerData)) + + logger?.info({ zipSizeKB: (zipBuffer.length / 1024).toFixed(2) }, 'ZIP file created successfully') + + // Validação de tamanho total (30MB limit para segurança) + const MAX_PACK_SIZE = 30 * 1024 * 1024 + if (zipBuffer.length > MAX_PACK_SIZE) { + throw new Boom( + `Total pack size exceeds ${MAX_PACK_SIZE / 1024 / 1024}MB limit. ` + + `Current size: ${(zipBuffer.length / 1024 / 1024).toFixed(2)}MB. ` + + `Try compressing stickers or reducing pack size.`, + { statusCode: 400 } + ) + } + + // 6. Upload do ZIP criptografado + logger?.trace('Uploading encrypted sticker pack ZIP') + const stickerPackUpload = await uploadMedia(zipBuffer, 'sticker-pack') + + // 7. Gera thumbnail 252x252 JPEG + logger?.trace('Generating thumbnail (252x252 JPEG)') + const lib = await getImageProcessingLibrary() + let thumbnailBuffer: Buffer + + if (!lib?.sharp) { + throw new Boom( + 'Sharp library is required for thumbnail generation. Install with: yarn add sharp', + { statusCode: 400 } + ) + } + + thumbnailBuffer = await lib + .sharp(coverBuffer) + .resize(252, 252, { fit: 'cover', position: 'center' }) + .jpeg({ quality: 85 }) + .toBuffer() + + logger?.trace({ thumbnailSizeKB: (thumbnailBuffer.length / 1024).toFixed(2) }, 'Thumbnail generated') + + // 8. Upload do thumbnail (REUTILIZA mesma mediaKey - requerido pelo protocolo!) + logger?.trace('Uploading thumbnail with same mediaKey') + const thumbUpload = await uploadMedia(thumbnailBuffer, 'thumbnail-sticker-pack', { + mediaKey: stickerPackUpload.mediaKey // CRÍTICO: mesma chave! + }) + + // 9. Monta mensagem protobuf + logger?.info( + { + packId: stickerPackId, + totalStickers: stickers.length, + uniqueFiles: uniqueFiles - 1, // minus cover + zipSizeKB: (zipBuffer.length / 1024).toFixed(2) + }, + 'Sticker pack message prepared successfully' + ) + + return proto.Message.StickerPackMessage.create({ + // Metadata do pack + stickerPackId, + name, + publisher, + packDescription: description, + stickerPackOrigin: proto.Message.StickerPackMessage.StickerPackOrigin.USER_CREATED, + stickerPackSize: zipBuffer.length, + stickers: stickerMetadata, + + // ZIP file (criptografado) + fileSha256: stickerPackUpload.fileSha256, + fileEncSha256: stickerPackUpload.fileEncSha256, + mediaKey: stickerPackUpload.mediaKey, + directPath: stickerPackUpload.directPath, + fileLength: zipBuffer.length, + mediaKeyTimestamp: stickerPackUpload.mediaKeyTimestamp, + + // Tray icon info + trayIconFileName: coverFileName, + + // Thumbnail (criptografado com mesma key) + thumbnailDirectPath: thumbUpload.directPath, + thumbnailSha256: createHash('sha256').update(thumbnailBuffer).digest(), + thumbnailEncSha256: thumbUpload.fileEncSha256, + thumbnailHeight: 252, + thumbnailWidth: 252, + imageDataHash: createHash('sha256').update(thumbnailBuffer).digest('base64') + }) +} diff --git a/yarn.lock b/yarn.lock index 16f90fdc..2145fb9e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2841,6 +2841,7 @@ __metadata: eslint: "npm:^9" eslint-config-prettier: "npm:^10.1.2" eslint-plugin-prettier: "npm:^5.4.0" + fflate: "npm:^0.8.2" jest: "npm:^30.0.5" jimp: "npm:^1.6.0" jiti: "npm:^2.4.2" @@ -4668,6 +4669,13 @@ __metadata: languageName: node linkType: hard +"fflate@npm:^0.8.2": + version: 0.8.2 + resolution: "fflate@npm:0.8.2" + checksum: 10c0/03448d630c0a583abea594835a9fdb2aaf7d67787055a761515bf4ed862913cfd693b4c4ffd5c3f3b355a70cf1e19033e9ae5aedcca103188aaff91b8bd6e293 + languageName: node + linkType: hard + "figures@npm:^5.0.0": version: 5.0.0 resolution: "figures@npm:5.0.0"