From 8119c5b2ba768f6cdbdc1e1e2dac8d352b7efac9 Mon Sep 17 00:00:00 2001 From: trevor-signal <131492920+trevor-signal@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:45:37 -0400 Subject: [PATCH] Improve processing of sticker pack manifest --- ts/test-node/types/Stickers_test.preload.ts | 118 ++++++++++++++++++- ts/types/Stickers.preload.ts | 119 +++++++++++--------- 2 files changed, 184 insertions(+), 53 deletions(-) diff --git a/ts/test-node/types/Stickers_test.preload.ts b/ts/test-node/types/Stickers_test.preload.ts index f364acae34..036cb7f9ac 100644 --- a/ts/test-node/types/Stickers_test.preload.ts +++ b/ts/test-node/types/Stickers_test.preload.ts @@ -4,6 +4,32 @@ import { assert } from 'chai'; import * as Stickers from '../../types/Stickers.preload.ts'; import { isPackIdValid, redactPackId } from '../../util/Stickers.std.ts'; +import type { SignalService as Proto } from '../../protobuf/index.std.ts'; + +const PACK_ID = 'b9439fa5fdc8b9873fe64f01b88b8ccf'; + +function makeSticker( + id: number, + emoji: string | null = null +): Proto.StickerPack.Sticker { + return { $unknown: [], id, emoji }; +} + +function makeManifest({ + cover = null, + stickers = [], +}: { + cover?: Proto.StickerPack.Sticker | null; + stickers?: Array; +}): Proto.StickerPack { + return { + $unknown: [], + title: 'title', + author: 'author', + cover, + stickers, + }; +} describe('Stickers', () => { describe('getDataFromLink', () => { @@ -129,7 +155,7 @@ describe('Stickers', () => { }); it('returns true for valid pack IDs', () => { - assert.isTrue(isPackIdValid('b9439fa5fdc8b9873fe64f01b88b8ccf')); + assert.isTrue(isPackIdValid(PACK_ID)); assert.isTrue(isPackIdValid('3eff225a1036a58a7530b312dd92f8d8')); assert.isTrue(isPackIdValid('DDFD48B8097DA7A4E928192B10963F6A')); }); @@ -143,4 +169,94 @@ describe('Stickers', () => { ); }); }); + + describe('parseStickerPackManifest', () => { + it('throws if the pack has no cover and no stickers', () => { + assert.throws( + () => Stickers.parseStickerPackManifest(PACK_ID, makeManifest({})), + /no cover, and no stickers/ + ); + }); + + it('truncates a pack with more stickers than the maximum', () => { + const result = Stickers.parseStickerPackManifest( + PACK_ID, + makeManifest({ + stickers: Array.from( + { length: Stickers.MAX_STICKERS_PER_PACK * 10 }, + (_, id) => makeSticker(id) + ), + }) + ); + + assert.strictEqual(result.stickerCount, Stickers.MAX_STICKERS_PER_PACK); + // The cover is the first sticker, so it isn't in nonCoverStickers + assert.strictEqual(result.coverStickerId, 0); + assert.strictEqual( + result.nonCoverStickers.length, + Stickers.MAX_STICKERS_PER_PACK - 1 + ); + }); + + it('falls back to the first sticker as the cover', () => { + const stickers = [makeSticker(0), makeSticker(1), makeSticker(2)]; + + const result = Stickers.parseStickerPackManifest( + PACK_ID, + makeManifest({ stickers }) + ); + + assert.strictEqual(result.coverStickerId, 0); + assert.isTrue(result.coverIncludedInList); + assert.strictEqual(result.stickerCount, 3); + assert.deepEqual( + result.nonCoverStickers.map(sticker => sticker.id), + [1, 2] + ); + }); + + it('handles a cover that is not one of the stickers', () => { + const result = Stickers.parseStickerPackManifest( + PACK_ID, + makeManifest({ + cover: makeSticker(99), + stickers: [makeSticker(0), makeSticker(1)], + }) + ); + + assert.strictEqual(result.coverStickerId, 99); + assert.isFalse(result.coverIncludedInList); + assert.deepEqual( + result.nonCoverStickers.map(sticker => sticker.id), + [0, 1] + ); + }); + + it('takes the emoji from the sticker list if the cover has none', () => { + const result = Stickers.parseStickerPackManifest( + PACK_ID, + makeManifest({ + cover: makeSticker(1), + stickers: [makeSticker(1, '😀'), makeSticker(2, '😉')], + }) + ); + + assert.strictEqual(result.coverProto.emoji, '😀'); + }); + + it('drops stickers with no id', () => { + const result = Stickers.parseStickerPackManifest( + PACK_ID, + makeManifest({ + cover: makeSticker(0), + stickers: [makeSticker(1), { $unknown: [], id: null, emoji: null }], + }) + ); + + assert.deepEqual( + result.nonCoverStickers.map(sticker => sticker.id), + [1] + ); + }); + }); }); diff --git a/ts/types/Stickers.preload.ts b/ts/types/Stickers.preload.ts index af5e0719f6..6e8fa3dde9 100644 --- a/ts/types/Stickers.preload.ts +++ b/ts/types/Stickers.preload.ts @@ -54,7 +54,9 @@ import { import { getExistingAttachmentDataForReuse } from '../util/attachments/deduplicateAttachment.preload.ts'; import { Emoji } from '../axo/emoji.std.ts'; -const { isNumber, reject, groupBy, values, chunk } = lodash; +const { isNumber, groupBy, values, chunk } = lodash; + +export const MAX_STICKERS_PER_PACK = 1024; const log = createLogger('Stickers'); @@ -524,6 +526,57 @@ export async function removeEphemeralPack(packId: string): Promise { await DataWriter.deleteStickerPack(packId); } +export function parseStickerPackManifest( + packId: string, + proto: Proto.StickerPack +): { + coverProto: Proto.StickerPack.Sticker.Params; + coverStickerId: number; + coverIncludedInList: boolean; + nonCoverStickers: Array; + stickerCount: number; +} { + let { stickers } = proto; + + if (stickers.length > MAX_STICKERS_PER_PACK) { + log.warn( + `parseStickerPackManifest: pack ${redactPackId(packId)} has ` + + `${stickers.length} stickers, truncating to ${MAX_STICKERS_PER_PACK}` + ); + stickers = stickers.slice(0, MAX_STICKERS_PER_PACK); + } + + const stickerCount = stickers.length; + + const coverProto = proto.cover || stickers[0]; + const coverStickerId = dropNull(coverProto ? coverProto.id : undefined); + + if (!coverProto || !isNumber(coverStickerId)) { + throw new Error( + `Sticker pack ${redactPackId( + packId + )} is malformed - it has no cover, and no stickers` + ); + } + + const coverSticker = stickers.find(sticker => sticker.id === coverStickerId); + const nonCoverStickers = stickers.filter( + sticker => sticker.id != null && sticker.id !== coverStickerId + ); + + if (coverSticker && !coverProto.emoji) { + coverProto.emoji = coverSticker.emoji; + } + + return { + coverProto, + coverStickerId, + coverIncludedInList: nonCoverStickers.length < stickerCount, + nonCoverStickers, + stickerCount, + }; +} + export async function downloadEphemeralPack( packId: string, packKey: string @@ -569,32 +622,13 @@ export async function downloadEphemeralPack( const ciphertext = await getStickerPackManifest(packId); const plaintext = decryptSticker(packKey, ciphertext); const proto = Proto.StickerPack.decode(plaintext); - const firstStickerProto = proto.stickers ? proto.stickers[0] : null; - const stickerCount = proto.stickers.length; - - const coverProto = proto.cover || firstStickerProto; - const coverStickerId = coverProto ? coverProto.id : null; - - if (!coverProto || !isNumber(coverStickerId)) { - throw new Error( - `Sticker pack ${redactPackId( - packId - )} is malformed - it has no cover, and no stickers` - ); - } - - const nonCoverStickers = reject( - proto.stickers, - sticker => !isNumber(sticker.id) || sticker.id === coverStickerId - ); - const coverSticker = proto.stickers.filter( - sticker => isNumber(sticker.id) && sticker.id === coverStickerId - ); - if (coverSticker[0] && !coverProto.emoji) { - coverProto.emoji = coverSticker[0].emoji; - } - - const coverIncludedInList = nonCoverStickers.length < stickerCount; + const { + coverProto, + coverStickerId, + coverIncludedInList, + nonCoverStickers, + stickerCount, + } = parseStickerPackManifest(packId, proto); const pack = { ...STICKER_PACK_DEFAULTS, @@ -812,32 +846,13 @@ async function doDownloadStickerPack( const ciphertext = await getStickerPackManifest(packId); const plaintext = decryptSticker(packKey, ciphertext); const proto = Proto.StickerPack.decode(plaintext); - const firstStickerProto = proto.stickers ? proto.stickers[0] : undefined; - const stickerCount = proto.stickers.length; + const parsed = parseStickerPackManifest(packId, proto); + const { stickerCount } = parsed; - coverProto = proto.cover || firstStickerProto; - coverStickerId = dropNull(coverProto ? coverProto.id : undefined); - - if (!coverProto || !isNumber(coverStickerId)) { - throw new Error( - `Sticker pack ${redactPackId( - packId - )} is malformed - it has no cover, and no stickers` - ); - } - - nonCoverStickers = reject( - proto.stickers, - sticker => !isNumber(sticker.id) || sticker.id === coverStickerId - ); - const coverSticker = proto.stickers.filter( - sticker => isNumber(sticker.id) && sticker.id === coverStickerId - ); - if (coverSticker[0] && !coverProto.emoji) { - coverProto.emoji = coverSticker[0].emoji; - } - - coverIncludedInList = nonCoverStickers.length < stickerCount; + coverProto = parsed.coverProto; + coverStickerId = parsed.coverStickerId; + coverIncludedInList = parsed.coverIncludedInList; + nonCoverStickers = parsed.nonCoverStickers; // status can be: // - 'known'