More backup export improvements

This commit is contained in:
trevor-signal
2026-03-16 13:20:53 -07:00
committed by GitHub
parent b45db69a03
commit 8d6738127a
3 changed files with 201 additions and 48 deletions

View File

@@ -1376,7 +1376,7 @@ export class BackupExportStream extends Readable {
labelEmoji: null, labelEmoji: null,
labelString: null, labelString: null,
}), }),
role: member.role, role: member.role || SignalService.Member.Role.DEFAULT,
userId: this.#aciToBytes(member.aci), userId: this.#aciToBytes(member.aci),
}; };
}) ?? null, }) ?? null,
@@ -1385,7 +1385,7 @@ export class BackupExportStream extends Readable {
return { return {
member: { member: {
userId: this.#serviceIdToBytes(member.serviceId), userId: this.#serviceIdToBytes(member.serviceId),
role: member.role, role: member.role || SignalService.Member.Role.DEFAULT,
joinedAtVersion: 0, joinedAtVersion: 0,
labelEmoji: null, labelEmoji: null,
labelString: null, labelString: null,
@@ -1478,8 +1478,12 @@ export class BackupExportStream extends Readable {
let authorId: bigint | undefined; let authorId: bigint | undefined;
const isOutgoing = message.type === 'outgoing'; const me = this.#getOrPushPrivateRecipient({
const isIncoming = message.type === 'incoming'; serviceId: aboutMe.aci,
});
let isOutgoing = message.type === 'outgoing';
let isIncoming = message.type === 'incoming';
if (message.sourceServiceId && isAciString(message.sourceServiceId)) { if (message.sourceServiceId && isAciString(message.sourceServiceId)) {
authorId = this.#getOrPushPrivateRecipient({ authorId = this.#getOrPushPrivateRecipient({
@@ -1491,23 +1495,6 @@ export class BackupExportStream extends Readable {
serviceId: message.sourceServiceId, serviceId: message.sourceServiceId,
e164: message.source, 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 { } else {
strictAssert(!isIncoming, 'Incoming message must have source'); strictAssert(!isIncoming, 'Incoming message must have source');
@@ -1517,6 +1504,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) { if (isOutgoing || isIncoming) {
strictAssert(authorId, 'Incoming/outgoing messages require an author'); strictAssert(authorId, 'Incoming/outgoing messages require an author');
} }
@@ -1570,9 +1590,6 @@ export class BackupExportStream extends Readable {
authorId, authorId,
'Incoming/outgoing non-bubble messages require an author' 'Incoming/outgoing non-bubble messages require an author'
); );
const me = this.#getOrPushPrivateRecipient({
serviceId: aboutMe.aci,
});
if (authorId === me) { if (authorId === me) {
directionalDetails = { directionalDetails = {
@@ -1849,10 +1866,16 @@ export class BackupExportStream extends Readable {
} else if (message.isErased) { } else if (message.isErased) {
return undefined; return undefined;
} else { } else {
const standardMessage = await this.#toStandardMessage({
message,
});
if (!standardMessage) {
return undefined;
}
item = { item = {
standardMessage: await this.#toStandardMessage({ standardMessage,
message,
}),
}; };
revisions = await this.#toChatItemRevisions(base, item, message); revisions = await this.#toChatItemRevisions(base, item, message);
@@ -3339,7 +3362,7 @@ export class BackupExportStream extends Readable {
| 'received_at' | 'received_at'
| 'timestamp' | 'timestamp'
>; >;
}): Promise<Backups.StandardMessage.Params> { }): Promise<Backups.StandardMessage.Params | undefined> {
if ( if (
message.body && message.body &&
isBodyTooLong(message.body, MAX_BACKUP_MESSAGE_BODY_BYTE_LENGTH) isBodyTooLong(message.body, MAX_BACKUP_MESSAGE_BODY_BYTE_LENGTH)
@@ -3347,7 +3370,7 @@ export class BackupExportStream extends Readable {
log.warn(`${message.timestamp}: Message body is too long; will truncate`); log.warn(`${message.timestamp}: Message body is too long; will truncate`);
} }
return { const result = {
quote: await this.#toQuote({ quote: await this.#toQuote({
message, message,
}), }),
@@ -3384,6 +3407,12 @@ export class BackupExportStream extends Readable {
: null, : null,
reactions: this.#getMessageReactions(message), 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({ async #toDirectStoryReplyMessage({
@@ -3488,7 +3517,7 @@ export class BackupExportStream extends Readable {
const isOutgoing = message.type === 'outgoing'; const isOutgoing = message.type === 'outgoing';
return Promise.all( const revisions = await Promise.all(
editHistory editHistory
// The first history is the copy of the current message // The first history is the copy of the current message
.slice(1) .slice(1)
@@ -3526,10 +3555,16 @@ export class BackupExportStream extends Readable {
}), }),
}; };
} else { } else {
const standardMessage = await this.#toStandardMessage({
message: history,
});
if (!standardMessage) {
log.warn('Chat revision was invalid, dropping');
return null;
}
item = { item = {
standardMessage: await this.#toStandardMessage({ standardMessage,
message: history,
}),
}; };
} }
return { ...base, item }; return { ...base, item };
@@ -3537,6 +3572,7 @@ export class BackupExportStream extends Readable {
// Backups use oldest to newest order // Backups use oldest to newest order
.reverse() .reverse()
); );
return revisions.filter(isNotNil);
} }
#toCustomChatColors(): Array<Backups.ChatStyle.CustomChatColor.Params> { #toCustomChatColors(): Array<Backups.ChatStyle.CustomChatColor.Params> {

View File

@@ -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 () => { it('roundtrips unopened gift badge', async () => {
await symmetricRoundtripHarness([ 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', () => { describe('quotes', () => {
it('roundtrips gift badge quote', async () => { it('roundtrips gift badge quote', async () => {
await symmetricRoundtripHarness([ await symmetricRoundtripHarness([

View File

@@ -877,19 +877,15 @@ const bodyRangeOffsetSchema = z.number().int().min(0);
const bodyRangeStyleSchema = z.nativeEnum(signalservice.BodyRange.Style); const bodyRangeStyleSchema = z.nativeEnum(signalservice.BodyRange.Style);
export const bodyRangeSchema = z.union([ export const bodyRangeSchema = z.union([
z z.object({
.object({ start: bodyRangeOffsetSchema,
start: bodyRangeOffsetSchema, length: bodyRangeOffsetSchema,
length: bodyRangeOffsetSchema, mentionAci: aciSchema,
mentionAci: aciSchema, }),
}) z.object({
.strict(), start: bodyRangeOffsetSchema,
z length: bodyRangeOffsetSchema,
.object({ style: bodyRangeStyleSchema,
start: bodyRangeOffsetSchema, spoilerId: z.number().optional(),
length: bodyRangeOffsetSchema, }),
style: bodyRangeStyleSchema,
spoilerId: z.number().optional(),
})
.strict(),
]); ]);