From e20be50f01fdbe935516dd662c081d44c6df2af0 Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Tue, 21 Jan 2025 16:40:34 -0600 Subject: [PATCH] Backups: support direct story replies Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- ACKNOWLEDGMENTS.md | 4 +- package-lock.json | 9 +- package.json | 2 +- protos/Backups.proto | 19 +- ts/services/backups/export.ts | 95 +++++++++- ts/services/backups/import.ts | 109 +++++++++-- ts/test-electron/backup/bubble_test.ts | 207 +++++++++++++++++++++ ts/test-electron/backup/non_bubble_test.ts | 2 +- 9 files changed, 417 insertions(+), 32 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b81924fdb..5330c48c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -194,7 +194,7 @@ jobs: uses: actions/checkout@v4 with: repository: 'signalapp/Signal-Message-Backup-Tests' - ref: '05062de9656c5ed7c7e6c6a49897b42e7ad083fc' + ref: '22d7f507b61691e0a7da1fd4b233f219bdaf2280' path: 'backup-integration-tests' - run: xvfb-run --auto-servernum npm run test-electron diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index 96ee6d89c..5af0f8a89 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -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 @@ -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) diff --git a/package-lock.json b/package-lock.json index c282da793..488af5ab7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "@react-aria/utils": "3.25.3", "@react-spring/web": "9.7.5", "@signalapp/better-sqlite3": "9.0.9", - "@signalapp/libsignal-client": "0.65.0", + "@signalapp/libsignal-client": "0.65.2", "@signalapp/ringrtc": "2.49.3", "@types/fabric": "4.5.3", "backbone": "1.6.0", @@ -6471,11 +6471,10 @@ } }, "node_modules/@signalapp/libsignal-client": { - "version": "0.65.0", - "resolved": "https://registry.npmjs.org/@signalapp/libsignal-client/-/libsignal-client-0.65.0.tgz", - "integrity": "sha512-6bb+454/ZoOu6EhHdKHSFVoNKRhka1FhqfiDL/FYLf79g+Nz4kXNxCMRJwYO3hzPAgjjs/6Q7S1mXr9CfS7Kkg==", + "version": "0.65.2", + "resolved": "https://registry.npmjs.org/@signalapp/libsignal-client/-/libsignal-client-0.65.2.tgz", + "integrity": "sha512-OfemOQcVxykG8fwUwqkbxt1Vg0HZSGrrWG8wqjJbXj93kOOmQNkpuiJlfm+NBqNYkTYnwRy8rd6PQbu04q7NeA==", "hasInstallScript": true, - "license": "AGPL-3.0-only", "dependencies": { "node-gyp-build": "^4.8.0", "type-fest": "^4.26.0", diff --git a/package.json b/package.json index 510744a33..e8489d5ef 100644 --- a/package.json +++ b/package.json @@ -113,7 +113,7 @@ "@react-aria/utils": "3.25.3", "@react-spring/web": "9.7.5", "@signalapp/better-sqlite3": "9.0.9", - "@signalapp/libsignal-client": "0.65.0", + "@signalapp/libsignal-client": "0.65.2", "@signalapp/ringrtc": "2.49.3", "@types/fabric": "4.5.3", "backbone": "1.6.0", diff --git a/protos/Backups.proto b/protos/Backups.proto index 591be6587..2977db9a3 100644 --- a/protos/Backups.proto +++ b/protos/Backups.proto @@ -173,6 +173,7 @@ message Contact { optional bytes identityKey = 14; IdentityState identityState = 15; Name nickname = 16; // absent iff both `given` and `family` are empty + string note = 17; } message Group { @@ -380,6 +381,7 @@ message ChatItem { PaymentNotification paymentNotification = 16; GiftBadge giftBadge = 17; ViewOnceMessage viewOnceMessage = 18; + DirectStoryReplyMessage directStoryReplyMessage = 19; // group story reply messages are not backed up } } @@ -448,6 +450,21 @@ message ContactMessage { 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 TransactionDetails { message MobileCoinTxoIdentification { // Used to map to payments on the ledger @@ -1241,4 +1258,4 @@ message ChatFolder { FolderType folderType = 6; 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 -} +} \ No newline at end of file diff --git a/ts/services/backups/export.ts b/ts/services/backups/export.ts index 04e7df93d..a18e06141 100644 --- a/ts/services/backups/export.ts +++ b/ts/services/backups/export.ts @@ -842,7 +842,7 @@ export class BackupExportStream extends Readable { identityKey = identityKeysById.get(convo.serviceId); } - const { nicknameGivenName, nicknameFamilyName } = convo; + const { nicknameGivenName, nicknameFamilyName, note } = convo; res.contact = { aci: @@ -887,6 +887,7 @@ export class BackupExportStream extends Readable { family: nicknameFamilyName, } : null, + note, }; } else if (isGroupV2(convo) && convo.masterKey) { let storySendMode: Backups.Group.StorySendMode; @@ -994,6 +995,19 @@ export class BackupExportStream extends Readable { 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); if (expirationTimestamp != null && expirationTimestamp <= this.#now + DAY) { // Message expires too soon @@ -1220,6 +1234,17 @@ export class BackupExportStream extends Readable { state, }; } + } else if (message.storyReplyContext) { + result.directStoryReplyMessage = await this.#toDirectStoryReplyMessage({ + message, + backupLevel, + }); + + result.revisions = await this.#toChatItemRevisions( + result, + message, + backupLevel + ); } else { result.standardMessage = await this.#toStandardMessage({ 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 { + 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({ message, backupLevel, @@ -2568,7 +2638,7 @@ export class BackupExportStream extends Readable { // The first history is the copy of the current message .slice(1) .map(async history => { - return { + const result: Backups.IChatItem = { // Required fields chatId: parent.chatId, authorId: parent.authorId, @@ -2586,16 +2656,23 @@ export class BackupExportStream extends Readable { incoming: isOutgoing ? undefined : 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() ); } diff --git a/ts/services/backups/import.ts b/ts/services/backups/import.ts index 764544cd4..745bed16d 100644 --- a/ts/services/backups/import.ts +++ b/ts/services/backups/import.ts @@ -914,6 +914,7 @@ export class BackupImportStream extends Writable { expireTimerVersion: 1, nicknameGivenName: dropNull(contact.nickname?.given), nicknameFamilyName: dropNull(contact.nickname?.family), + note: dropNull(contact.note), }; if (serviceId != null && Bytes.isNotEmpty(contact.identityKey)) { @@ -1441,6 +1442,26 @@ export class BackupImportStream extends Writable { ...attributes, ...(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 { const result = await this.#fromNonBubbleChatItem(item, { aboutMe, @@ -1472,8 +1493,8 @@ export class BackupImportStream extends Writable { if (item.revisions?.length) { strictAssert( - item.standardMessage, - `${logId}: Only standard message can have revisions` + item.standardMessage || item.directStoryReplyMessage, + `${logId}: Only standard or story reply message can have revisions` ); const history = await this.#fromRevisions({ @@ -1793,9 +1814,6 @@ export class BackupImportStream extends Writable { isStory: false, }) ) { - if (isNightly(window.getVersion())) { - throw new Error(`${logId}: dropping invalid link preview`); - } log.warn(`${logId}: dropping invalid link preview`); return; } @@ -1836,6 +1854,55 @@ export class BackupImportStream extends Writable { }; } + #fromDirectStoryReplyMessage( + directStoryReplyMessage: Backups.IDirectStoryReplyMessage, + storyAuthorAci: AciString + ): Partial { + const { reactions, textReply, emoji } = directStoryReplyMessage; + + const result: Partial = { + 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> { + 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({ mainMessage, revisions, @@ -1849,8 +1916,8 @@ export class BackupImportStream extends Writable { revisions .map(async rev => { strictAssert( - rev.standardMessage, - 'Edit history has non-standard messages' + rev.standardMessage || rev.directStoryReplyMessage, + 'Edit history on a message that does not support revisions' ); const timestamp = getCheckedTimestampFromLong(rev.dateSent); @@ -1866,11 +1933,7 @@ export class BackupImportStream extends Writable { }, } = this.#fromDirectionDetails(rev, timestamp); - return { - ...(await this.#fromStandardMessage({ - logId, - data: rev.standardMessage, - })), + const commonFields = { timestamp, received_at: incrementMessageCounter(), sendStateByConversationId, @@ -1880,6 +1943,28 @@ export class BackupImportStream extends Writable { readStatus, 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 .reverse() diff --git a/ts/test-electron/backup/bubble_test.ts b/ts/test-electron/backup/bubble_test.ts index b90e83f58..21aabb600 100644 --- a/ts/test-electron/backup/bubble_test.ts +++ b/ts/test-electron/backup/bubble_test.ts @@ -24,6 +24,8 @@ import { import { loadAllAndReinitializeRedux } from '../../services/allLoaders'; import { strictAssert } from '../../util/assert'; 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_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; + + 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; + + 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; + + 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; + + 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], []); + }); + }); }); diff --git a/ts/test-electron/backup/non_bubble_test.ts b/ts/test-electron/backup/non_bubble_test.ts index 25315568d..ad36e865f 100644 --- a/ts/test-electron/backup/non_bubble_test.ts +++ b/ts/test-electron/backup/non_bubble_test.ts @@ -584,7 +584,7 @@ describe('backup/non-bubble messages', () => { id: generateGuid(), type: 'message-request-response-event', received_at: 1, - sourceServiceId: CONTACT_A, + sourceServiceId: OUR_ACI, sourceDevice: 1, readStatus: ReadStatus.Read, seenStatus: SeenStatus.Seen,