Skip to content

Commit 6bf7984

Browse files
authored
Add backup support for polls
1 parent 64d5eb9 commit 6bf7984

File tree

9 files changed

+497
-4
lines changed

9 files changed

+497
-4
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ jobs:
234234
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
235235
with:
236236
repository: 'signalapp/Signal-Message-Backup-Tests'
237-
ref: 'ae41153c5ac776b138b778b82fa593be23b3a14c'
237+
ref: '455fbe5854bd3be5002f17ae929a898c0975adc4'
238238
path: 'backup-integration-tests'
239239

240240
- run: xvfb-run --auto-servernum pnpm run test-electron

protos/Backups.proto

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,7 @@ message ChatItem {
428428
GiftBadge giftBadge = 17;
429429
ViewOnceMessage viewOnceMessage = 18;
430430
DirectStoryReplyMessage directStoryReplyMessage = 19; // group story reply messages are not backed up
431+
Poll poll = 20;
431432
}
432433
}
433434

@@ -760,6 +761,7 @@ message Quote {
760761
NORMAL = 1;
761762
GIFT_BADGE = 2;
762763
VIEW_ONCE = 3;
764+
POLL = 4;
763765
}
764766

765767
message QuotedAttachment {
@@ -806,6 +808,31 @@ message Reaction {
806808
uint64 sortOrder = 4;
807809
}
808810

811+
message Poll {
812+
813+
message PollOption {
814+
815+
message PollVote {
816+
uint64 voterId = 1; // A direct reference to Recipient proto id. Must be self or contact.
817+
uint32 voteCount = 2; // Tracks how many times you voted.
818+
}
819+
820+
string option = 1; // Between 1-100 characters
821+
repeated PollVote votes = 2;
822+
}
823+
824+
string question = 1; // Between 1-100 characters
825+
bool allowMultiple = 2;
826+
repeated PollOption options = 3; // At least two
827+
bool hasEnded = 4;
828+
repeated Reaction reactions = 5;
829+
}
830+
831+
message PollTerminateUpdate {
832+
uint64 targetSentTimestamp = 1;
833+
string question = 2; // Between 1-100 characters
834+
}
835+
809836
message ChatUpdateMessage {
810837
// If unset, importers should ignore the update message without throwing an error.
811838
oneof update {
@@ -818,6 +845,7 @@ message ChatUpdateMessage {
818845
IndividualCall individualCall = 7;
819846
GroupCall groupCall = 8;
820847
LearnedProfileChatUpdate learnedProfileChange = 9;
848+
PollTerminateUpdate pollTerminate = 10;
821849
}
822850
}
823851

ts/model-types.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export type QuotedMessageType = {
103103
// from backup
104104
id: number | null;
105105
isGiftBadge?: boolean;
106+
isPoll?: boolean;
106107
isViewOnce: boolean;
107108
referencedMessageNotFound: boolean;
108109
text?: string;

ts/services/backups/export.preload.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1499,6 +1499,67 @@ export class BackupExportStream extends Readable {
14991499
});
15001500

15011501
result.revisions = await this.#toChatItemRevisions(result, message);
1502+
} else if (message.poll) {
1503+
const { poll } = message;
1504+
const pollMessage = new Backups.Poll();
1505+
1506+
pollMessage.question = poll.question;
1507+
pollMessage.allowMultiple = poll.allowMultiple;
1508+
pollMessage.hasEnded = poll.terminatedAt != null;
1509+
1510+
pollMessage.options = poll.options.map((optionText, optionIndex) => {
1511+
const pollOption = new Backups.Poll.PollOption();
1512+
pollOption.option = optionText;
1513+
1514+
const votesForThisOption = new Map<string, number>();
1515+
1516+
if (poll.votes) {
1517+
for (const vote of poll.votes) {
1518+
// Skip votes that have not been sent
1519+
if (vote.sendStateByConversationId) {
1520+
continue;
1521+
}
1522+
1523+
// If we somehow have multiple votes from the same person
1524+
// (shouldn't happen, just in case) only keep the highest voteCount
1525+
const maybeExistingVoteFromThisConversation =
1526+
votesForThisOption.get(vote.fromConversationId);
1527+
if (
1528+
vote.optionIndexes.includes(optionIndex) &&
1529+
(!maybeExistingVoteFromThisConversation ||
1530+
vote.voteCount > maybeExistingVoteFromThisConversation)
1531+
) {
1532+
votesForThisOption.set(vote.fromConversationId, vote.voteCount);
1533+
}
1534+
}
1535+
}
1536+
1537+
pollOption.votes = Array.from(votesForThisOption.entries()).map(
1538+
([conversationId, voteCount]) => {
1539+
const pollVote = new Backups.Poll.PollOption.PollVote();
1540+
1541+
const voterConvo =
1542+
window.ConversationController.get(conversationId);
1543+
if (voterConvo) {
1544+
pollVote.voterId = this.#getOrPushPrivateRecipient(
1545+
voterConvo.attributes
1546+
);
1547+
}
1548+
1549+
pollVote.voteCount = voteCount;
1550+
return pollVote;
1551+
}
1552+
);
1553+
1554+
return pollOption;
1555+
});
1556+
1557+
const reactions = this.#getMessageReactions(message);
1558+
if (reactions != null) {
1559+
pollMessage.reactions = reactions;
1560+
}
1561+
1562+
result.poll = pollMessage;
15021563
} else {
15031564
result.standardMessage = await this.#toStandardMessage({
15041565
message,
@@ -2451,6 +2512,8 @@ export class BackupExportStream extends Readable {
24512512
quoteType = Backups.Quote.Type.GIFT_BADGE;
24522513
} else if (quote.isViewOnce) {
24532514
quoteType = Backups.Quote.Type.VIEW_ONCE;
2515+
} else if (quote.isPoll) {
2516+
quoteType = Backups.Quote.Type.POLL;
24542517
} else {
24552518
quoteType = Backups.Quote.Type.NORMAL;
24562519
if (quote.text == null && quote.attachments.length === 0) {

ts/services/backups/import.preload.ts

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,8 @@ type ChatItemParseResult = {
180180
additionalMessages: Array<Partial<MessageAttributesType>>;
181181
};
182182

183+
const SKIP = 'SKIP' as const;
184+
183185
function phoneToContactFormType(
184186
type: Backups.ContactAttachment.Phone.Type | null | undefined
185187
): ContactFormType {
@@ -1564,6 +1566,63 @@ export class BackupImportStream extends Writable {
15641566
storyAuthorAci
15651567
),
15661568
};
1569+
} else if (item.poll) {
1570+
const { poll } = item;
1571+
1572+
const votesByVoter = new Map<
1573+
string,
1574+
{
1575+
fromConversationId: string;
1576+
optionIndexes: Array<number>;
1577+
voteCount: number;
1578+
timestamp: number;
1579+
}
1580+
>();
1581+
1582+
poll.options?.forEach((option, optionIndex) => {
1583+
option.votes?.forEach(vote => {
1584+
if (!vote.voterId) {
1585+
return;
1586+
}
1587+
1588+
const conversation = this.#recipientIdToConvo.get(
1589+
vote.voterId.toNumber()
1590+
);
1591+
if (!conversation) {
1592+
log.warn(`${logId}: Poll vote has unknown voterId ${vote.voterId}`);
1593+
return;
1594+
}
1595+
1596+
const conversationId = conversation.id;
1597+
1598+
let voterRecord = votesByVoter.get(conversationId);
1599+
if (!voterRecord) {
1600+
voterRecord = {
1601+
fromConversationId: conversationId,
1602+
optionIndexes: [],
1603+
voteCount: vote.voteCount ?? 1,
1604+
timestamp,
1605+
};
1606+
votesByVoter.set(conversationId, voterRecord);
1607+
}
1608+
1609+
voterRecord.optionIndexes.push(optionIndex);
1610+
});
1611+
});
1612+
1613+
const votes = Array.from(votesByVoter.values());
1614+
1615+
attributes = {
1616+
...attributes,
1617+
poll: {
1618+
question: poll.question ?? '',
1619+
options: poll.options?.map(option => option.option ?? '') ?? [],
1620+
allowMultiple: poll.allowMultiple ?? false,
1621+
votes: votes.length > 0 ? votes : undefined,
1622+
terminatedAt: poll.hasEnded ? Number(item.dateSent) : undefined,
1623+
},
1624+
reactions: this.#fromReactions(poll.reactions),
1625+
};
15671626
} else {
15681627
const result = await this.#fromNonBubbleChatItem(item, {
15691628
aboutMe,
@@ -1572,6 +1631,10 @@ export class BackupImportStream extends Writable {
15721631
timestamp,
15731632
});
15741633

1634+
if (result === SKIP) {
1635+
return;
1636+
}
1637+
15751638
if (!result) {
15761639
throw new Error(`${logId}: fromNonBubbleChat item returned nothing!`);
15771640
}
@@ -2159,6 +2222,7 @@ export class BackupImportStream extends Writable {
21592222
text: dropNull(quote.text?.body),
21602223
bodyRanges: this.#fromBodyRanges(quote.text),
21612224
isGiftBadge: quote.type === Backups.Quote.Type.GIFT_BADGE,
2225+
isPoll: quote.type === Backups.Quote.Type.POLL ? true : undefined,
21622226
isViewOnce: quote.type === Backups.Quote.Type.VIEW_ONCE,
21632227
attachments:
21642228
quote.attachments?.map(quotedAttachment => {
@@ -2242,7 +2306,7 @@ export class BackupImportStream extends Writable {
22422306
conversation: ConversationAttributesType;
22432307
timestamp: number;
22442308
}
2245-
): Promise<ChatItemParseResult | undefined> {
2309+
): Promise<ChatItemParseResult | undefined | typeof SKIP> {
22462310
const { timestamp } = options;
22472311
const logId = `fromChatItemToNonBubble(${timestamp})`;
22482312

@@ -2477,7 +2541,7 @@ export class BackupImportStream extends Writable {
24772541
conversation: ConversationAttributesType;
24782542
timestamp: number;
24792543
}
2480-
): Promise<ChatItemParseResult | undefined> {
2544+
): Promise<ChatItemParseResult | undefined | typeof SKIP> {
24812545
const { aboutMe, author, conversation } = options;
24822546

24832547
if (updateMessage.groupChange) {
@@ -2733,6 +2797,11 @@ export class BackupImportStream extends Writable {
27332797
};
27342798
}
27352799

2800+
if (updateMessage.pollTerminate) {
2801+
log.info('Skipping pollTerminate update (not yet supported)');
2802+
return SKIP;
2803+
}
2804+
27362805
return undefined;
27372806
}
27382807

0 commit comments

Comments
 (0)