Improve processing of sticker pack manifest

This commit is contained in:
trevor-signal
2026-08-03 10:45:37 -07:00
committed by GitHub
parent fda64ddfce
commit 8119c5b2ba
2 changed files with 184 additions and 53 deletions
+117 -1
View File
@@ -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.Sticker>;
}): 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]
);
});
});
});
+67 -52
View File
@@ -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<void> {
await DataWriter.deleteStickerPack(packId);
}
export function parseStickerPackManifest(
packId: string,
proto: Proto.StickerPack
): {
coverProto: Proto.StickerPack.Sticker.Params;
coverStickerId: number;
coverIncludedInList: boolean;
nonCoverStickers: Array<Proto.StickerPack.Sticker.Params>;
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'