Add backup support for polls

Co-authored-by: yash-signal <yash@signal.org>
This commit is contained in:
automated-signal
2025-11-03 12:48:03 -06:00
committed by GitHub
parent 65617ef479
commit 1c736636fe
9 changed files with 497 additions and 4 deletions

View File

@@ -234,7 +234,7 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
repository: 'signalapp/Signal-Message-Backup-Tests'
ref: 'ae41153c5ac776b138b778b82fa593be23b3a14c'
ref: '455fbe5854bd3be5002f17ae929a898c0975adc4'
path: 'backup-integration-tests'
- run: xvfb-run --auto-servernum pnpm run test-electron

View File

@@ -428,6 +428,7 @@ message ChatItem {
GiftBadge giftBadge = 17;
ViewOnceMessage viewOnceMessage = 18;
DirectStoryReplyMessage directStoryReplyMessage = 19; // group story reply messages are not backed up
Poll poll = 20;
}
}
@@ -760,6 +761,7 @@ message Quote {
NORMAL = 1;
GIFT_BADGE = 2;
VIEW_ONCE = 3;
POLL = 4;
}
message QuotedAttachment {
@@ -806,6 +808,31 @@ message Reaction {
uint64 sortOrder = 4;
}
message Poll {
message PollOption {
message PollVote {
uint64 voterId = 1; // A direct reference to Recipient proto id. Must be self or contact.
uint32 voteCount = 2; // Tracks how many times you voted.
}
string option = 1; // Between 1-100 characters
repeated PollVote votes = 2;
}
string question = 1; // Between 1-100 characters
bool allowMultiple = 2;
repeated PollOption options = 3; // At least two
bool hasEnded = 4;
repeated Reaction reactions = 5;
}
message PollTerminateUpdate {
uint64 targetSentTimestamp = 1;
string question = 2; // Between 1-100 characters
}
message ChatUpdateMessage {
// If unset, importers should ignore the update message without throwing an error.
oneof update {
@@ -818,6 +845,7 @@ message ChatUpdateMessage {
IndividualCall individualCall = 7;
GroupCall groupCall = 8;
LearnedProfileChatUpdate learnedProfileChange = 9;
PollTerminateUpdate pollTerminate = 10;
}
}

1
ts/model-types.d.ts vendored
View File

@@ -103,6 +103,7 @@ export type QuotedMessageType = {
// from backup
id: number | null;
isGiftBadge?: boolean;
isPoll?: boolean;
isViewOnce: boolean;
referencedMessageNotFound: boolean;
text?: string;

View File

@@ -1499,6 +1499,67 @@ export class BackupExportStream extends Readable {
});
result.revisions = await this.#toChatItemRevisions(result, message);
} else if (message.poll) {
const { poll } = message;
const pollMessage = new Backups.Poll();
pollMessage.question = poll.question;
pollMessage.allowMultiple = poll.allowMultiple;
pollMessage.hasEnded = poll.terminatedAt != null;
pollMessage.options = poll.options.map((optionText, optionIndex) => {
const pollOption = new Backups.Poll.PollOption();
pollOption.option = optionText;
const votesForThisOption = new Map<string, number>();
if (poll.votes) {
for (const vote of poll.votes) {
// Skip votes that have not been sent
if (vote.sendStateByConversationId) {
continue;
}
// If we somehow have multiple votes from the same person
// (shouldn't happen, just in case) only keep the highest voteCount
const maybeExistingVoteFromThisConversation =
votesForThisOption.get(vote.fromConversationId);
if (
vote.optionIndexes.includes(optionIndex) &&
(!maybeExistingVoteFromThisConversation ||
vote.voteCount > maybeExistingVoteFromThisConversation)
) {
votesForThisOption.set(vote.fromConversationId, vote.voteCount);
}
}
}
pollOption.votes = Array.from(votesForThisOption.entries()).map(
([conversationId, voteCount]) => {
const pollVote = new Backups.Poll.PollOption.PollVote();
const voterConvo =
window.ConversationController.get(conversationId);
if (voterConvo) {
pollVote.voterId = this.#getOrPushPrivateRecipient(
voterConvo.attributes
);
}
pollVote.voteCount = voteCount;
return pollVote;
}
);
return pollOption;
});
const reactions = this.#getMessageReactions(message);
if (reactions != null) {
pollMessage.reactions = reactions;
}
result.poll = pollMessage;
} else {
result.standardMessage = await this.#toStandardMessage({
message,
@@ -2451,6 +2512,8 @@ export class BackupExportStream extends Readable {
quoteType = Backups.Quote.Type.GIFT_BADGE;
} else if (quote.isViewOnce) {
quoteType = Backups.Quote.Type.VIEW_ONCE;
} else if (quote.isPoll) {
quoteType = Backups.Quote.Type.POLL;
} else {
quoteType = Backups.Quote.Type.NORMAL;
if (quote.text == null && quote.attachments.length === 0) {

View File

@@ -180,6 +180,8 @@ type ChatItemParseResult = {
additionalMessages: Array<Partial<MessageAttributesType>>;
};
const SKIP = 'SKIP' as const;
function phoneToContactFormType(
type: Backups.ContactAttachment.Phone.Type | null | undefined
): ContactFormType {
@@ -1564,6 +1566,63 @@ export class BackupImportStream extends Writable {
storyAuthorAci
),
};
} else if (item.poll) {
const { poll } = item;
const votesByVoter = new Map<
string,
{
fromConversationId: string;
optionIndexes: Array<number>;
voteCount: number;
timestamp: number;
}
>();
poll.options?.forEach((option, optionIndex) => {
option.votes?.forEach(vote => {
if (!vote.voterId) {
return;
}
const conversation = this.#recipientIdToConvo.get(
vote.voterId.toNumber()
);
if (!conversation) {
log.warn(`${logId}: Poll vote has unknown voterId ${vote.voterId}`);
return;
}
const conversationId = conversation.id;
let voterRecord = votesByVoter.get(conversationId);
if (!voterRecord) {
voterRecord = {
fromConversationId: conversationId,
optionIndexes: [],
voteCount: vote.voteCount ?? 1,
timestamp,
};
votesByVoter.set(conversationId, voterRecord);
}
voterRecord.optionIndexes.push(optionIndex);
});
});
const votes = Array.from(votesByVoter.values());
attributes = {
...attributes,
poll: {
question: poll.question ?? '',
options: poll.options?.map(option => option.option ?? '') ?? [],
allowMultiple: poll.allowMultiple ?? false,
votes: votes.length > 0 ? votes : undefined,
terminatedAt: poll.hasEnded ? Number(item.dateSent) : undefined,
},
reactions: this.#fromReactions(poll.reactions),
};
} else {
const result = await this.#fromNonBubbleChatItem(item, {
aboutMe,
@@ -1572,6 +1631,10 @@ export class BackupImportStream extends Writable {
timestamp,
});
if (result === SKIP) {
return;
}
if (!result) {
throw new Error(`${logId}: fromNonBubbleChat item returned nothing!`);
}
@@ -2159,6 +2222,7 @@ export class BackupImportStream extends Writable {
text: dropNull(quote.text?.body),
bodyRanges: this.#fromBodyRanges(quote.text),
isGiftBadge: quote.type === Backups.Quote.Type.GIFT_BADGE,
isPoll: quote.type === Backups.Quote.Type.POLL ? true : undefined,
isViewOnce: quote.type === Backups.Quote.Type.VIEW_ONCE,
attachments:
quote.attachments?.map(quotedAttachment => {
@@ -2242,7 +2306,7 @@ export class BackupImportStream extends Writable {
conversation: ConversationAttributesType;
timestamp: number;
}
): Promise<ChatItemParseResult | undefined> {
): Promise<ChatItemParseResult | undefined | typeof SKIP> {
const { timestamp } = options;
const logId = `fromChatItemToNonBubble(${timestamp})`;
@@ -2477,7 +2541,7 @@ export class BackupImportStream extends Writable {
conversation: ConversationAttributesType;
timestamp: number;
}
): Promise<ChatItemParseResult | undefined> {
): Promise<ChatItemParseResult | undefined | typeof SKIP> {
const { aboutMe, author, conversation } = options;
if (updateMessage.groupChange) {
@@ -2733,6 +2797,11 @@ export class BackupImportStream extends Writable {
};
}
if (updateMessage.pollTerminate) {
log.info('Skipping pollTerminate update (not yet supported)');
return SKIP;
}
return undefined;
}

View File

@@ -1184,4 +1184,320 @@ describe('backup/bubble messages', () => {
]);
});
});
describe('polls', () => {
const GROUP_ID = Bytes.toBase64(getRandomBytes(32));
let group: ConversationModel | undefined;
let basePollMessage: MessageAttributesType;
beforeEach(async () => {
group = await window.ConversationController.getOrCreateAndWait(
GROUP_ID,
'group',
{
groupVersion: 2,
masterKey: Bytes.toBase64(getRandomBytes(32)),
name: 'Poll Test Group',
active_at: 1,
}
);
strictAssert(group, 'group must exist');
basePollMessage = {
conversationId: group.id,
id: generateGuid(),
type: 'incoming',
received_at: 3,
received_at_ms: 3,
sent_at: 3,
sourceServiceId: CONTACT_A,
readStatus: ReadStatus.Unread,
seenStatus: SeenStatus.Unseen,
unidentifiedDeliveryReceived: true,
timestamp: 3,
poll: {
question: '',
options: [],
allowMultiple: false,
votes: undefined,
terminatedAt: undefined,
},
};
});
it('roundtrips poll with no votes', async () => {
await symmetricRoundtripHarness([
{
...basePollMessage,
poll: {
question: 'How do you feel about unit testing?',
options: ['yay', 'ok', 'nay'],
allowMultiple: false,
},
},
]);
});
it('roundtrips poll with single vote', async () => {
await symmetricRoundtripHarness([
{
...basePollMessage,
poll: {
question: 'How do you feel about unit testing?',
options: ['yay', 'ok', 'nay'],
allowMultiple: false,
votes: [
{
fromConversationId: contactA.id,
optionIndexes: [0], // Voted for "yay"
voteCount: 1,
timestamp: 3,
},
],
},
},
]);
});
it('roundtrips poll with multiple voters', async () => {
await symmetricRoundtripHarness([
{
...basePollMessage,
poll: {
question: 'Pizza toppings?',
options: ['pepperoni', 'mushrooms', 'pineapple'],
allowMultiple: false,
votes: [
{
fromConversationId: contactA.id,
optionIndexes: [0], // contactA voted for pepperoni
voteCount: 2, // Changed their vote twice
timestamp: 3,
},
{
fromConversationId: contactB.id,
optionIndexes: [2], // contactB voted for pineapple
voteCount: 1,
timestamp: 3,
},
],
},
},
]);
});
it('roundtrips poll with multiple selections', async () => {
await symmetricRoundtripHarness([
{
...basePollMessage,
poll: {
question: 'Which features do you want?',
options: ['dark mode', 'better search', 'polls', 'voice notes'],
allowMultiple: true,
votes: [
{
fromConversationId: contactA.id,
optionIndexes: [0, 2, 3], // Selected dark mode, polls, and voice notes
voteCount: 1,
timestamp: 3,
},
],
},
},
]);
});
it('roundtrips ended poll', async () => {
const pollData = {
poll: {
question: 'This poll is closed',
options: ['option1', 'option2'],
allowMultiple: false,
votes: [
{
fromConversationId: contactA.id,
optionIndexes: [0],
voteCount: 1,
timestamp: 3,
},
],
},
};
await asymmetricRoundtripHarness(
[
{
...basePollMessage,
...pollData,
poll: {
...pollData.poll,
terminatedAt: 5, // Original termination timestamp
},
},
],
[
{
...basePollMessage,
...pollData,
poll: {
...pollData.poll,
terminatedAt: 3, // After roundtrip, set to message timestamp
},
},
]
);
});
it('roundtrips outgoing poll', async () => {
await symmetricRoundtripHarness([
{
...basePollMessage,
type: 'outgoing',
sourceServiceId: OUR_ACI,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
unidentifiedDeliveryReceived: false, // Outgoing messages default to false
sendStateByConversationId: {
[contactA.id]: {
status: SendStatus.Delivered,
},
},
poll: {
question: 'Meeting time?',
options: ['10am', '2pm', '4pm'],
allowMultiple: false,
votes: [
{
fromConversationId: contactA.id,
optionIndexes: [1],
voteCount: 1,
timestamp: 3,
},
],
},
},
]);
});
it('excludes pending votes from backup', async () => {
strictAssert(group, 'group must exist');
const ourConversation = window.ConversationController.get(OUR_ACI);
strictAssert(ourConversation, 'our conversation must exist');
await asymmetricRoundtripHarness(
[
{
...basePollMessage,
poll: {
question: 'Test question?',
options: ['yes', 'no'],
allowMultiple: false,
votes: [
{
fromConversationId: contactA.id,
optionIndexes: [0],
voteCount: 1,
timestamp: 3,
// No sendStateByConversationId - this vote is sent
},
{
fromConversationId: ourConversation.id,
optionIndexes: [1],
voteCount: 1,
timestamp: 3,
// This vote is still pending, should NOT be in backup
sendStateByConversationId: {
[contactA.id]: {
status: SendStatus.Pending,
updatedAt: 3,
},
},
},
],
},
},
],
[
{
...basePollMessage,
poll: {
question: 'Test question?',
options: ['yes', 'no'],
allowMultiple: false,
votes: [
{
fromConversationId: contactA.id,
optionIndexes: [0],
voteCount: 1,
timestamp: 3,
// Only the sent vote should remain after roundtrip
},
// The pending vote should be excluded from the backup
],
},
},
]
);
});
it('roundtrips poll with reactions', async () => {
await symmetricRoundtripHarness([
{
...basePollMessage,
poll: {
question: 'React to this poll?',
options: ['yes', 'no'],
allowMultiple: false,
},
reactions: [
{
emoji: '👍',
fromId: contactA.id,
targetTimestamp: 3,
timestamp: 3,
},
{
emoji: '❤️',
fromId: contactB.id,
targetTimestamp: 3,
timestamp: 3,
},
],
},
]);
});
it('roundtrips quote of poll', async () => {
await symmetricRoundtripHarness([
{
...basePollMessage,
poll: {
question: 'Original poll?',
options: ['option1', 'option2'],
allowMultiple: false,
},
},
{
...basePollMessage,
id: generateGuid(), // Generate new ID to avoid duplicate
sent_at: 4,
timestamp: 4,
body: 'Replying to poll',
poll: undefined,
quote: {
id: 3,
authorAci: CONTACT_A,
text: 'Original poll?',
attachments: [],
isGiftBadge: false,
isPoll: true,
isViewOnce: false,
referencedMessageNotFound: false,
},
},
]);
});
});
});

View File

@@ -79,6 +79,7 @@ function sortAndNormalize(
preview,
quote,
sticker,
poll,
// This is not in the backup
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -176,6 +177,18 @@ function sortAndNormalize(
data: omit(sticker.data, 'downloadPath'),
}
: undefined,
poll: poll
? {
...poll,
votes: poll.votes?.map(vote => ({
...vote,
fromConversationId: mapConvoId(vote.fromConversationId),
sendStateByConversationId: mapSendState(
vote.sendStateByConversationId
),
})),
}
: undefined,
// Not an original property, but useful
isUnsupported: isUnsupportedMessage(message),

View File

@@ -81,7 +81,9 @@ describe('backup/integration', () => {
if (
expectedString.includes('ReleaseChannelDonationRequest') ||
// TODO (DESKTOP-8025) roundtrip these frames
fullPath.includes('chat_folder')
fullPath.includes('chat_folder') ||
// TODO (DESKTOP-9209) roundtrip these frames when feature is added
fullPath.includes('poll_terminate')
) {
// Skip the unsupported tests
return;

View File

@@ -55,6 +55,7 @@ export async function makeQuote(
id: quoteId,
isViewOnce: isTapToView(quotedMessage),
isGiftBadge: isGiftBadge(quotedMessage),
isPoll: quotedMessage.poll != null,
messageId,
referencedMessageNotFound: false,
text: getQuoteBodyText({