Backups: support direct story replies

This commit is contained in:
trevor-signal 2025-01-21 16:49:05 -05:00 committed by GitHub
parent 4b6ef3a1ed
commit 0d6cd429d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 417 additions and 32 deletions

View file

@ -194,7 +194,7 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
repository: 'signalapp/Signal-Message-Backup-Tests' repository: 'signalapp/Signal-Message-Backup-Tests'
ref: '05062de9656c5ed7c7e6c6a49897b42e7ad083fc' ref: '22d7f507b61691e0a7da1fd4b233f219bdaf2280'
path: 'backup-integration-tests' path: 'backup-integration-tests'
- run: xvfb-run --auto-servernum npm run test-electron - run: xvfb-run --auto-servernum npm run test-electron

View file

@ -6052,7 +6052,7 @@ third-party/chromium/LICENSE.
``` ```
## windows-core 0.52.0, windows-sys 0.45.0, windows-sys 0.52.0, windows-sys 0.59.0, windows-targets 0.42.2, windows-targets 0.48.5, windows-targets 0.52.6, windows_aarch64_msvc 0.42.2, windows_aarch64_msvc 0.48.5, windows_aarch64_msvc 0.52.6, windows_x86_64_gnu 0.48.5, windows_x86_64_gnu 0.52.6, windows_x86_64_msvc 0.42.2, windows_x86_64_msvc 0.48.5, windows_x86_64_msvc 0.52.6 ## windows-core 0.52.0, windows-sys 0.45.0, windows-sys 0.52.0, windows-sys 0.59.0, windows-targets 0.42.2, windows-targets 0.52.6, windows_aarch64_msvc 0.42.2, windows_aarch64_msvc 0.52.6, windows_x86_64_gnu 0.52.6, windows_x86_64_msvc 0.42.2, windows_x86_64_msvc 0.52.6
``` ```
MIT License MIT License
@ -10809,7 +10809,7 @@ SOFTWARE.
``` ```
## derive_more 0.99.18 ## derive_more-impl 1.0.0, derive_more 0.99.18, derive_more 1.0.0
``` ```
The MIT License (MIT) The MIT License (MIT)

9
package-lock.json generated
View file

@ -22,7 +22,7 @@
"@react-aria/utils": "3.25.3", "@react-aria/utils": "3.25.3",
"@react-spring/web": "9.7.5", "@react-spring/web": "9.7.5",
"@signalapp/better-sqlite3": "9.0.9", "@signalapp/better-sqlite3": "9.0.9",
"@signalapp/libsignal-client": "0.65.0", "@signalapp/libsignal-client": "0.65.2",
"@signalapp/ringrtc": "2.49.3", "@signalapp/ringrtc": "2.49.3",
"@types/fabric": "4.5.3", "@types/fabric": "4.5.3",
"backbone": "1.6.0", "backbone": "1.6.0",
@ -6471,11 +6471,10 @@
} }
}, },
"node_modules/@signalapp/libsignal-client": { "node_modules/@signalapp/libsignal-client": {
"version": "0.65.0", "version": "0.65.2",
"resolved": "https://registry.npmjs.org/@signalapp/libsignal-client/-/libsignal-client-0.65.0.tgz", "resolved": "https://registry.npmjs.org/@signalapp/libsignal-client/-/libsignal-client-0.65.2.tgz",
"integrity": "sha512-6bb+454/ZoOu6EhHdKHSFVoNKRhka1FhqfiDL/FYLf79g+Nz4kXNxCMRJwYO3hzPAgjjs/6Q7S1mXr9CfS7Kkg==", "integrity": "sha512-OfemOQcVxykG8fwUwqkbxt1Vg0HZSGrrWG8wqjJbXj93kOOmQNkpuiJlfm+NBqNYkTYnwRy8rd6PQbu04q7NeA==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"node-gyp-build": "^4.8.0", "node-gyp-build": "^4.8.0",
"type-fest": "^4.26.0", "type-fest": "^4.26.0",

View file

@ -113,7 +113,7 @@
"@react-aria/utils": "3.25.3", "@react-aria/utils": "3.25.3",
"@react-spring/web": "9.7.5", "@react-spring/web": "9.7.5",
"@signalapp/better-sqlite3": "9.0.9", "@signalapp/better-sqlite3": "9.0.9",
"@signalapp/libsignal-client": "0.65.0", "@signalapp/libsignal-client": "0.65.2",
"@signalapp/ringrtc": "2.49.3", "@signalapp/ringrtc": "2.49.3",
"@types/fabric": "4.5.3", "@types/fabric": "4.5.3",
"backbone": "1.6.0", "backbone": "1.6.0",

View file

@ -173,6 +173,7 @@ message Contact {
optional bytes identityKey = 14; optional bytes identityKey = 14;
IdentityState identityState = 15; IdentityState identityState = 15;
Name nickname = 16; // absent iff both `given` and `family` are empty Name nickname = 16; // absent iff both `given` and `family` are empty
string note = 17;
} }
message Group { message Group {
@ -380,6 +381,7 @@ message ChatItem {
PaymentNotification paymentNotification = 16; PaymentNotification paymentNotification = 16;
GiftBadge giftBadge = 17; GiftBadge giftBadge = 17;
ViewOnceMessage viewOnceMessage = 18; ViewOnceMessage viewOnceMessage = 18;
DirectStoryReplyMessage directStoryReplyMessage = 19; // group story reply messages are not backed up
} }
} }
@ -448,6 +450,21 @@ message ContactMessage {
repeated Reaction reactions = 2; repeated Reaction reactions = 2;
} }
message DirectStoryReplyMessage {
message TextReply {
Text text = 1;
FilePointer longText = 2;
}
oneof reply {
TextReply textReply = 1;
string emoji = 2;
}
repeated Reaction reactions = 3;
reserved /*storySentTimestamp*/ 4;
}
message PaymentNotification { message PaymentNotification {
message TransactionDetails { message TransactionDetails {
message MobileCoinTxoIdentification { // Used to map to payments on the ledger message MobileCoinTxoIdentification { // Used to map to payments on the ledger
@ -1241,4 +1258,4 @@ message ChatFolder {
FolderType folderType = 6; FolderType folderType = 6;
repeated uint64 includedRecipientIds = 7; // generated recipient id of groups, contacts, and/or note to self repeated uint64 includedRecipientIds = 7; // generated recipient id of groups, contacts, and/or note to self
repeated uint64 excludedRecipientIds = 8; // generated recipient id of groups, contacts, and/or note to self repeated uint64 excludedRecipientIds = 8; // generated recipient id of groups, contacts, and/or note to self
} }

View file

@ -842,7 +842,7 @@ export class BackupExportStream extends Readable {
identityKey = identityKeysById.get(convo.serviceId); identityKey = identityKeysById.get(convo.serviceId);
} }
const { nicknameGivenName, nicknameFamilyName } = convo; const { nicknameGivenName, nicknameFamilyName, note } = convo;
res.contact = { res.contact = {
aci: aci:
@ -887,6 +887,7 @@ export class BackupExportStream extends Readable {
family: nicknameFamilyName, family: nicknameFamilyName,
} }
: null, : null,
note,
}; };
} else if (isGroupV2(convo) && convo.masterKey) { } else if (isGroupV2(convo) && convo.masterKey) {
let storySendMode: Backups.Group.StorySendMode; let storySendMode: Backups.Group.StorySendMode;
@ -994,6 +995,19 @@ export class BackupExportStream extends Readable {
return undefined; return undefined;
} }
if (message.type === 'story') {
return undefined;
}
if (
conversation &&
isGroupV2(conversation.attributes) &&
message.storyReplyContext
) {
// We drop group story replies
return undefined;
}
const expirationTimestamp = calculateExpirationTimestamp(message); const expirationTimestamp = calculateExpirationTimestamp(message);
if (expirationTimestamp != null && expirationTimestamp <= this.#now + DAY) { if (expirationTimestamp != null && expirationTimestamp <= this.#now + DAY) {
// Message expires too soon // Message expires too soon
@ -1220,6 +1234,17 @@ export class BackupExportStream extends Readable {
state, state,
}; };
} }
} else if (message.storyReplyContext) {
result.directStoryReplyMessage = await this.#toDirectStoryReplyMessage({
message,
backupLevel,
});
result.revisions = await this.#toChatItemRevisions(
result,
message,
backupLevel
);
} else { } else {
result.standardMessage = await this.#toStandardMessage({ result.standardMessage = await this.#toStandardMessage({
message, message,
@ -2527,6 +2552,51 @@ export class BackupExportStream extends Readable {
}; };
} }
async #toDirectStoryReplyMessage({
message,
backupLevel,
}: {
message: Pick<
MessageAttributesType,
| 'body'
| 'bodyAttachment'
| 'bodyRanges'
| 'storyReaction'
| 'storyReplyContext'
| 'received_at'
| 'reactions'
>;
backupLevel: BackupLevel;
}): Promise<Backups.IDirectStoryReplyMessage> {
const result = new Backups.DirectStoryReplyMessage({
reactions: this.#getMessageReactions(message),
});
if (message.storyReaction) {
result.emoji = message.storyReaction.emoji;
} else {
result.textReply = {
longText: message.bodyAttachment
? await this.#processAttachment({
attachment: message.bodyAttachment,
backupLevel,
messageReceivedAt: message.received_at,
})
: undefined,
text:
message.body != null
? {
body: message.body ? trimBody(message.body) : undefined,
bodyRanges: message.bodyRanges?.map(range =>
this.#toBodyRange(range)
),
}
: undefined,
};
}
return result;
}
async #toViewOnceMessage({ async #toViewOnceMessage({
message, message,
backupLevel, backupLevel,
@ -2568,7 +2638,7 @@ export class BackupExportStream extends Readable {
// The first history is the copy of the current message // The first history is the copy of the current message
.slice(1) .slice(1)
.map(async history => { .map(async history => {
return { const result: Backups.IChatItem = {
// Required fields // Required fields
chatId: parent.chatId, chatId: parent.chatId,
authorId: parent.authorId, authorId: parent.authorId,
@ -2586,16 +2656,23 @@ export class BackupExportStream extends Readable {
incoming: isOutgoing incoming: isOutgoing
? undefined ? undefined
: this.#getIncomingMessageDetails(history), : this.#getIncomingMessageDetails(history),
// Message itself
standardMessage: await this.#toStandardMessage({
message: history,
backupLevel,
}),
}; };
// Backups use oldest to newest order if (parent.directStoryReplyMessage) {
result.directStoryReplyMessage =
await this.#toDirectStoryReplyMessage({
message: history,
backupLevel,
});
} else {
result.standardMessage = await this.#toStandardMessage({
message: history,
backupLevel,
});
}
return result;
}) })
// Backups use oldest to newest order
.reverse() .reverse()
); );
} }

View file

@ -914,6 +914,7 @@ export class BackupImportStream extends Writable {
expireTimerVersion: 1, expireTimerVersion: 1,
nicknameGivenName: dropNull(contact.nickname?.given), nicknameGivenName: dropNull(contact.nickname?.given),
nicknameFamilyName: dropNull(contact.nickname?.family), nicknameFamilyName: dropNull(contact.nickname?.family),
note: dropNull(contact.note),
}; };
if (serviceId != null && Bytes.isNotEmpty(contact.identityKey)) { if (serviceId != null && Bytes.isNotEmpty(contact.identityKey)) {
@ -1441,6 +1442,26 @@ export class BackupImportStream extends Writable {
...attributes, ...attributes,
...(await this.#fromViewOnceMessage(item.viewOnceMessage)), ...(await this.#fromViewOnceMessage(item.viewOnceMessage)),
}; };
} else if (item.directStoryReplyMessage) {
strictAssert(item.directionless == null, 'reply cannot be directionless');
let storyAuthorAci: AciString | undefined;
if (item.incoming) {
strictAssert(this.#aboutMe?.aci, 'about me must exist');
storyAuthorAci = this.#aboutMe.aci;
} else {
strictAssert(
isAciString(chatConvo.serviceId),
'must have ACI for story author'
);
storyAuthorAci = chatConvo.serviceId;
}
attributes = {
...attributes,
...this.#fromDirectStoryReplyMessage(
item.directStoryReplyMessage,
storyAuthorAci
),
};
} else { } else {
const result = await this.#fromNonBubbleChatItem(item, { const result = await this.#fromNonBubbleChatItem(item, {
aboutMe, aboutMe,
@ -1472,8 +1493,8 @@ export class BackupImportStream extends Writable {
if (item.revisions?.length) { if (item.revisions?.length) {
strictAssert( strictAssert(
item.standardMessage, item.standardMessage || item.directStoryReplyMessage,
`${logId}: Only standard message can have revisions` `${logId}: Only standard or story reply message can have revisions`
); );
const history = await this.#fromRevisions({ const history = await this.#fromRevisions({
@ -1793,9 +1814,6 @@ export class BackupImportStream extends Writable {
isStory: false, isStory: false,
}) })
) { ) {
if (isNightly(window.getVersion())) {
throw new Error(`${logId}: dropping invalid link preview`);
}
log.warn(`${logId}: dropping invalid link preview`); log.warn(`${logId}: dropping invalid link preview`);
return; return;
} }
@ -1836,6 +1854,55 @@ export class BackupImportStream extends Writable {
}; };
} }
#fromDirectStoryReplyMessage(
directStoryReplyMessage: Backups.IDirectStoryReplyMessage,
storyAuthorAci: AciString
): Partial<MessageAttributesType> {
const { reactions, textReply, emoji } = directStoryReplyMessage;
const result: Partial<MessageAttributesType> = {
reactions: this.#fromReactions(reactions),
storyReplyContext: {
authorAci: storyAuthorAci,
messageId: '', // stories are never imported
},
};
if (textReply) {
result.body = textReply.text?.body ?? undefined;
result.bodyRanges = this.#fromBodyRanges(textReply.text);
result.bodyAttachment = textReply.longText
? convertFilePointerToAttachment(textReply.longText)
: undefined;
} else if (emoji) {
result.storyReaction = {
emoji,
targetAuthorAci: storyAuthorAci,
targetTimestamp: 0, // stories are never imported
};
}
return result;
}
async #fromDirectStoryReplyRevision(
revision: Backups.IDirectStoryReplyMessage
): Promise<Partial<EditHistoryType>> {
const { textReply } = revision;
if (!textReply) {
return {};
}
return {
body: textReply.text?.body ?? undefined,
bodyRanges: this.#fromBodyRanges(textReply.text),
bodyAttachment: textReply.longText
? convertFilePointerToAttachment(textReply.longText)
: undefined,
};
}
async #fromRevisions({ async #fromRevisions({
mainMessage, mainMessage,
revisions, revisions,
@ -1849,8 +1916,8 @@ export class BackupImportStream extends Writable {
revisions revisions
.map(async rev => { .map(async rev => {
strictAssert( strictAssert(
rev.standardMessage, rev.standardMessage || rev.directStoryReplyMessage,
'Edit history has non-standard messages' 'Edit history on a message that does not support revisions'
); );
const timestamp = getCheckedTimestampFromLong(rev.dateSent); const timestamp = getCheckedTimestampFromLong(rev.dateSent);
@ -1866,11 +1933,7 @@ export class BackupImportStream extends Writable {
}, },
} = this.#fromDirectionDetails(rev, timestamp); } = this.#fromDirectionDetails(rev, timestamp);
return { const commonFields = {
...(await this.#fromStandardMessage({
logId,
data: rev.standardMessage,
})),
timestamp, timestamp,
received_at: incrementMessageCounter(), received_at: incrementMessageCounter(),
sendStateByConversationId, sendStateByConversationId,
@ -1880,6 +1943,28 @@ export class BackupImportStream extends Writable {
readStatus, readStatus,
unidentifiedDeliveryReceived, unidentifiedDeliveryReceived,
}; };
if (rev.standardMessage) {
return {
...(await this.#fromStandardMessage({
logId,
data: rev.standardMessage,
})),
...commonFields,
};
}
if (rev.directStoryReplyMessage) {
return {
...(await this.#fromDirectStoryReplyRevision(
rev.directStoryReplyMessage
)),
...commonFields,
};
}
throw new Error(
'Edit history on a message that does not support revisions'
);
}) })
// Fix order: from newest to oldest // Fix order: from newest to oldest
.reverse() .reverse()

View file

@ -24,6 +24,8 @@ import {
import { loadAllAndReinitializeRedux } from '../../services/allLoaders'; import { loadAllAndReinitializeRedux } from '../../services/allLoaders';
import { strictAssert } from '../../util/assert'; import { strictAssert } from '../../util/assert';
import type { MessageAttributesType } from '../../model-types'; import type { MessageAttributesType } from '../../model-types';
import { TEXT_ATTACHMENT } from '../../types/MIME';
import { MY_STORY_ID } from '../../types/Stories';
const CONTACT_A = generateAci(); const CONTACT_A = generateAci();
const CONTACT_B = generateAci(); const CONTACT_B = generateAci();
@ -817,4 +819,209 @@ describe('backup/bubble messages', () => {
]); ]);
}); });
}); });
describe('stories', () => {
const GROUP_ID = Bytes.toBase64(getRandomBytes(32));
let group: ConversationModel | undefined;
let ourConversation: ConversationModel | undefined;
beforeEach(async () => {
group = await window.ConversationController.getOrCreateAndWait(
GROUP_ID,
'group',
{
groupVersion: 2,
masterKey: Bytes.toBase64(getRandomBytes(32)),
name: 'Rock Enthusiasts',
active_at: 1,
}
);
ourConversation = window.ConversationController.get(OUR_ACI);
});
it('does not export stories', async () => {
strictAssert(ourConversation, 'conversations exist');
strictAssert(group, 'conversations exist');
const commonProps = {
type: 'story',
received_at: 3,
received_at_ms: 3,
sent_at: 3,
timestamp: 3,
sourceServiceId: OUR_ACI,
attachments: [
{
contentType: TEXT_ATTACHMENT,
size: 4,
textAttachment: {
color: 4285041620,
text: 'test',
textForegroundColor: 4294967295,
textStyle: 1,
},
},
],
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
expirationStartTimestamp: Date.now(),
expireTimer: DurationInSeconds.fromMillis(WEEK),
} satisfies Partial<MessageAttributesType>;
const directStory: MessageAttributesType = {
...commonProps,
id: generateGuid(),
conversationId: ourConversation.id,
storyDistributionListId: MY_STORY_ID,
};
const groupStory: MessageAttributesType = {
...commonProps,
id: generateGuid(),
conversationId: group.id,
};
await asymmetricRoundtripHarness([directStory, groupStory], []);
});
it('roundtrips direct story emoji replies', async () => {
strictAssert(ourConversation, 'conversations exist');
const commonProps = {
received_at: 3,
received_at_ms: 3,
sent_at: 3,
timestamp: 3,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
conversationId: contactA.id,
} satisfies Partial<MessageAttributesType>;
const incomingReply: MessageAttributesType = {
...commonProps,
id: generateGuid(),
type: 'incoming',
unidentifiedDeliveryReceived: true,
sourceServiceId: CONTACT_A,
storyReaction: {
emoji: '🤷‍♂️',
targetAuthorAci: OUR_ACI,
targetTimestamp: 0, // targetTimestamp is not roundtripped
},
storyReplyContext: {
authorAci: OUR_ACI,
messageId: '',
},
};
const outgoingReply: MessageAttributesType = {
...commonProps,
id: generateGuid(),
type: 'outgoing',
sourceServiceId: OUR_ACI,
storyReaction: {
emoji: '🤷‍♂️',
targetAuthorAci: CONTACT_A,
targetTimestamp: 0, // targetTimestamp is not roundtripped
},
storyReplyContext: {
authorAci: CONTACT_A,
messageId: '',
},
sendStateByConversationId: {
[CONTACT_A]: {
status: SendStatus.Read,
updatedAt: 3,
},
},
};
await symmetricRoundtripHarness([incomingReply, outgoingReply]);
});
it('roundtrips direct story text replies', async () => {
strictAssert(ourConversation, 'conversations exist');
const commonProps = {
received_at: 3,
received_at_ms: 3,
sent_at: 3,
timestamp: 3,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
conversationId: contactA.id,
body: 'text reply to story',
} satisfies Partial<MessageAttributesType>;
const incomingReply: MessageAttributesType = {
...commonProps,
id: generateGuid(),
type: 'incoming',
unidentifiedDeliveryReceived: true,
sourceServiceId: CONTACT_A,
storyReplyContext: {
authorAci: OUR_ACI,
messageId: '',
},
};
const outgoingReply: MessageAttributesType = {
...commonProps,
id: generateGuid(),
type: 'outgoing',
sourceServiceId: OUR_ACI,
storyReplyContext: {
authorAci: CONTACT_A,
messageId: '',
},
sendStateByConversationId: {
[CONTACT_A]: {
status: SendStatus.Read,
updatedAt: 3,
},
},
};
await symmetricRoundtripHarness([incomingReply, outgoingReply]);
});
it('does not export group story replies', async () => {
strictAssert(ourConversation, 'conversations exist');
strictAssert(group, 'conversations exist');
const commonProps = {
conversationId: group.id,
received_at: 3,
received_at_ms: 3,
sent_at: 3,
timestamp: 3,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
body: 'text reply to story',
} satisfies Partial<MessageAttributesType>;
const incomingReply: MessageAttributesType = {
...commonProps,
id: generateGuid(),
type: 'incoming',
unidentifiedDeliveryReceived: true,
sourceServiceId: CONTACT_A,
storyReplyContext: {
authorAci: OUR_ACI,
messageId: '',
},
};
const outgoingReply: MessageAttributesType = {
...commonProps,
id: generateGuid(),
type: 'outgoing',
sourceServiceId: OUR_ACI,
storyReplyContext: {
authorAci: CONTACT_A,
messageId: '',
},
sendStateByConversationId: {
[CONTACT_A]: {
status: SendStatus.Read,
updatedAt: 3,
},
},
};
await asymmetricRoundtripHarness([incomingReply, outgoingReply], []);
});
});
}); });

View file

@ -583,7 +583,7 @@ describe('backup/non-bubble messages', () => {
id: generateGuid(), id: generateGuid(),
type: 'message-request-response-event', type: 'message-request-response-event',
received_at: 1, received_at: 1,
sourceServiceId: CONTACT_A, sourceServiceId: OUR_ACI,
sourceDevice: 1, sourceDevice: 1,
readStatus: ReadStatus.Read, readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen, seenStatus: SeenStatus.Seen,