From f87188015e10ab8b68c731694b76307c3ebb2025 Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:37:41 -0500 Subject: [PATCH] More backup export improvements Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com> --- ts/services/backups/export.preload.ts | 102 ++++++++++----- .../backup/bubble_test.preload.ts | 121 ++++++++++++++++++ ts/types/BodyRange.std.ts | 26 ++-- 3 files changed, 201 insertions(+), 48 deletions(-) diff --git a/ts/services/backups/export.preload.ts b/ts/services/backups/export.preload.ts index e8da223e94..25d2966fdd 100644 --- a/ts/services/backups/export.preload.ts +++ b/ts/services/backups/export.preload.ts @@ -1375,7 +1375,7 @@ export class BackupExportStream extends Readable { labelEmoji: null, labelString: null, }), - role: member.role, + role: member.role || SignalService.Member.Role.DEFAULT, userId: this.#aciToBytes(member.aci), }; }) ?? null, @@ -1384,7 +1384,7 @@ export class BackupExportStream extends Readable { return { member: { userId: this.#serviceIdToBytes(member.serviceId), - role: member.role, + role: member.role || SignalService.Member.Role.DEFAULT, joinedAtVersion: 0, labelEmoji: null, labelString: null, @@ -1477,8 +1477,12 @@ export class BackupExportStream extends Readable { let authorId: bigint | undefined; - const isOutgoing = message.type === 'outgoing'; - const isIncoming = message.type === 'incoming'; + const me = this.#getOrPushPrivateRecipient({ + serviceId: aboutMe.aci, + }); + + let isOutgoing = message.type === 'outgoing'; + let isIncoming = message.type === 'incoming'; if (message.sourceServiceId && isAciString(message.sourceServiceId)) { authorId = this.#getOrPushPrivateRecipient({ @@ -1490,23 +1494,6 @@ export class BackupExportStream extends Readable { serviceId: message.sourceServiceId, e164: message.source, }); - - if ( - isIncoming && - conversation && - isDirectConversation(conversation.attributes) - ) { - const convoAuthor = this.#getOrPushPrivateRecipient({ - id: conversation.attributes.id, - }); - - // Fix conversation id for misattributed e164-only incoming 1:1 - // messages. - if (authorId !== convoAuthor) { - authorId = convoAuthor; - this.#stats.fixedDirectMessages += 1; - } - } } else { strictAssert(!isIncoming, 'Incoming message must have source'); @@ -1516,6 +1503,39 @@ export class BackupExportStream extends Readable { }); } + // Mark incoming messages from self as outgoing + if (isIncoming && authorId === me) { + log.warn( + `${message.sent_at}: Found incoming message with author self, updating to outgoing` + ); + isOutgoing = true; + isIncoming = false; + + // eslint-disable-next-line no-param-reassign + message.type = 'outgoing'; + + this.#stats.fixedDirectMessages += 1; + } + + // Fix authorId for misattributed e164-only incoming 1:1 + // messages. + if ( + isIncoming && + !message.sourceServiceId && + message.source && + conversation && + isDirectConversation(conversation.attributes) + ) { + const convoAuthor = this.#getOrPushPrivateRecipient({ + id: conversation.attributes.id, + }); + + if (authorId !== convoAuthor) { + authorId = convoAuthor; + this.#stats.fixedDirectMessages += 1; + } + } + if (isOutgoing || isIncoming) { strictAssert(authorId, 'Incoming/outgoing messages require an author'); } @@ -1569,9 +1589,6 @@ export class BackupExportStream extends Readable { authorId, 'Incoming/outgoing non-bubble messages require an author' ); - const me = this.#getOrPushPrivateRecipient({ - serviceId: aboutMe.aci, - }); if (authorId === me) { directionalDetails = { @@ -1848,10 +1865,16 @@ export class BackupExportStream extends Readable { } else if (message.isErased) { return undefined; } else { + const standardMessage = await this.#toStandardMessage({ + message, + }); + + if (!standardMessage) { + return undefined; + } + item = { - standardMessage: await this.#toStandardMessage({ - message, - }), + standardMessage, }; revisions = await this.#toChatItemRevisions(base, item, message); @@ -3317,7 +3340,7 @@ export class BackupExportStream extends Readable { | 'received_at' | 'timestamp' >; - }): Promise { + }): Promise { if ( message.body && isBodyTooLong(message.body, MAX_BACKUP_MESSAGE_BODY_BYTE_LENGTH) @@ -3325,7 +3348,7 @@ export class BackupExportStream extends Readable { log.warn(`${message.timestamp}: Message body is too long; will truncate`); } - return { + const result = { quote: await this.#toQuote({ message, }), @@ -3362,6 +3385,12 @@ export class BackupExportStream extends Readable { : null, reactions: this.#getMessageReactions(message), }; + + if (!result.attachments?.length && !result.text) { + log.warn('toStandardMessage: had neither text nor attachments, dropping'); + return undefined; + } + return result; } async #toDirectStoryReplyMessage({ @@ -3466,7 +3495,7 @@ export class BackupExportStream extends Readable { const isOutgoing = message.type === 'outgoing'; - return Promise.all( + const revisions = await Promise.all( editHistory // The first history is the copy of the current message .slice(1) @@ -3504,10 +3533,16 @@ export class BackupExportStream extends Readable { }), }; } else { + const standardMessage = await this.#toStandardMessage({ + message: history, + }); + if (!standardMessage) { + log.warn('Chat revision was invalid, dropping'); + return null; + } + item = { - standardMessage: await this.#toStandardMessage({ - message: history, - }), + standardMessage, }; } return { ...base, item }; @@ -3515,6 +3550,7 @@ export class BackupExportStream extends Readable { // Backups use oldest to newest order .reverse() ); + return revisions.filter(isNotNil); } #toCustomChatColors(): Array { diff --git a/ts/test-electron/backup/bubble_test.preload.ts b/ts/test-electron/backup/bubble_test.preload.ts index 507e83b139..642f552598 100644 --- a/ts/test-electron/backup/bubble_test.preload.ts +++ b/ts/test-electron/backup/bubble_test.preload.ts @@ -187,6 +187,85 @@ describe('backup/bubble messages', () => { ]); }); + it('drops messages with neither text nor attachments', async () => { + await asymmetricRoundtripHarness( + [ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'incoming', + received_at: 3, + received_at_ms: 3, + sent_at: 3, + timestamp: 3, + sourceServiceId: CONTACT_A, + readStatus: ReadStatus.Unread, + seenStatus: SeenStatus.Unseen, + unidentifiedDeliveryReceived: true, + }, + ], + [] + ); + }); + + it('drops edited revisions with neither text nor attachments', async () => { + const message: MessageAttributesType = { + conversationId: contactA.id, + id: generateGuid(), + type: 'incoming', + received_at: 3, + received_at_ms: 3, + sent_at: 3, + timestamp: 3, + sourceServiceId: CONTACT_A, + body: 'd', + readStatus: ReadStatus.Unread, + seenStatus: SeenStatus.Unseen, + unidentifiedDeliveryReceived: true, + editMessageTimestamp: 5, + editMessageReceivedAtMs: 5, + editHistory: [ + { + body: 'd', + timestamp: 5, + received_at: 5, + received_at_ms: 5, + readStatus: ReadStatus.Unread, + unidentifiedDeliveryReceived: true, + }, + { + timestamp: 4, + received_at: 4, + received_at_ms: 4, + readStatus: ReadStatus.Unread, + unidentifiedDeliveryReceived: false, + }, + { + body: 'b', + timestamp: 3, + received_at: 3, + received_at_ms: 3, + readStatus: ReadStatus.Read, + unidentifiedDeliveryReceived: false, + }, + ], + }; + strictAssert(message.editHistory, 'edit history exists'); + const [currentRevision, , oldestRevision] = message.editHistory; + strictAssert(currentRevision, 'current revision exists'); + strictAssert(oldestRevision, 'oldest revision exists'); + + await asymmetricRoundtripHarness( + [message], + [ + { + ...message, + editHistory: [currentRevision, oldestRevision], + }, + ] + ); + }); + it('roundtrips unopened gift badge', async () => { await symmetricRoundtripHarness([ { @@ -275,6 +354,48 @@ describe('backup/bubble messages', () => { ); }); + it('updates incoming messages authored by self to outgoing', async () => { + const ourConversation = window.ConversationController.get(OUR_ACI); + strictAssert(ourConversation, 'our conversation exists'); + + await asymmetricRoundtripHarness( + [ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'incoming', + received_at: 3, + received_at_ms: 3, + sent_at: 3, + sourceServiceId: OUR_ACI, + readStatus: ReadStatus.Unread, + seenStatus: SeenStatus.Unseen, + unidentifiedDeliveryReceived: true, + timestamp: 3, + body: 'hello', + }, + ], + [ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'outgoing', + received_at: 3, + received_at_ms: 3, + sent_at: 3, + sourceServiceId: OUR_ACI, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, + timestamp: 3, + body: 'hello', + sendStateByConversationId: { + [ourConversation.id]: { status: SendStatus.Read, updatedAt: 3 }, + }, + }, + ] + ); + }); + describe('quotes', () => { it('roundtrips gift badge quote', async () => { await symmetricRoundtripHarness([ diff --git a/ts/types/BodyRange.std.ts b/ts/types/BodyRange.std.ts index be4562c997..323c0054df 100644 --- a/ts/types/BodyRange.std.ts +++ b/ts/types/BodyRange.std.ts @@ -877,19 +877,15 @@ const bodyRangeOffsetSchema = z.number().int().min(0); const bodyRangeStyleSchema = z.nativeEnum(signalservice.BodyRange.Style); export const bodyRangeSchema = z.union([ - z - .object({ - start: bodyRangeOffsetSchema, - length: bodyRangeOffsetSchema, - mentionAci: aciSchema, - }) - .strict(), - z - .object({ - start: bodyRangeOffsetSchema, - length: bodyRangeOffsetSchema, - style: bodyRangeStyleSchema, - spoilerId: z.number().optional(), - }) - .strict(), + z.object({ + start: bodyRangeOffsetSchema, + length: bodyRangeOffsetSchema, + mentionAci: aciSchema, + }), + z.object({ + start: bodyRangeOffsetSchema, + length: bodyRangeOffsetSchema, + style: bodyRangeStyleSchema, + spoilerId: z.number().optional(), + }), ]);