Fix handling of replies on multiple dist lists

This commit is contained in:
Fedor Indutny 2023-05-25 14:12:33 +02:00 committed by GitHub
parent 3c7502213b
commit 1941a33556
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 284 additions and 70 deletions

View file

@ -156,7 +156,7 @@ import { viewOnceOpenJobQueue } from '../jobs/viewOnceOpenJobQueue';
import { getMessageIdForLogging } from '../util/idForLogging'; import { getMessageIdForLogging } from '../util/idForLogging';
import { hasAttachmentDownloads } from '../util/hasAttachmentDownloads'; import { hasAttachmentDownloads } from '../util/hasAttachmentDownloads';
import { queueAttachmentDownloads } from '../util/queueAttachmentDownloads'; import { queueAttachmentDownloads } from '../util/queueAttachmentDownloads';
import { findStoryMessage } from '../util/findStoryMessage'; import { findStoryMessages } from '../util/findStoryMessage';
import { getStoryDataFromMessageAttributes } from '../services/storyLoader'; import { getStoryDataFromMessageAttributes } from '../services/storyLoader';
import type { ConversationQueueJobData } from '../jobs/conversationJobQueue'; import type { ConversationQueueJobData } from '../jobs/conversationJobQueue';
import { getMessageById } from '../messages/getMessageById'; import { getMessageById } from '../messages/getMessageById';
@ -2453,58 +2453,65 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
); );
} }
const [quote, storyQuote] = await Promise.all([ const { storyContext } = initialMessage;
let storyContextLogId = 'no storyContext';
if (storyContext) {
storyContextLogId =
`storyContext(${storyContext.sentTimestamp}, ` +
`${storyContext.authorUuid})`;
}
const [quote, storyQuotes] = await Promise.all([
this.copyFromQuotedMessage(initialMessage.quote, conversation.id), this.copyFromQuotedMessage(initialMessage.quote, conversation.id),
findStoryMessage(conversation.id, initialMessage.storyContext), findStoryMessages(conversation.id, storyContext),
]); ]);
if (initialMessage.storyContext && !storyQuote) { const storyQuote = storyQuotes.find(candidateQuote => {
const sendStateByConversationId =
candidateQuote.get('sendStateByConversationId') || {};
const sendState = sendStateByConversationId[sender.id];
const storyQuoteIsFromSelf =
candidateQuote.get('sourceUuid') ===
window.storage.user.getCheckedUuid().toString();
if (!storyQuoteIsFromSelf) {
return true;
}
if (sendState === undefined) {
return false;
}
if (!isDirectConversation(conversation.attributes)) {
return false;
}
return sendState.isAllowedToReplyToStory !== false;
});
if (storyContext && !storyQuote) {
if (!isDirectConversation(conversation.attributes)) { if (!isDirectConversation(conversation.attributes)) {
log.warn( log.warn(
`${idLog}: Received storyContext message in group but no matching story. Dropping.` `${idLog}: Received ${storyContextLogId} message in group but no matching story. Dropping.`
); );
confirm(); confirm();
return; return;
} }
log.warn(
`${idLog}: Received 1:1 storyContext message but no matching story. We'll try processing this message again later.`
);
if (storyQuotes.length === 0) {
log.warn(
`${idLog}: Received ${storyContextLogId} message but no matching story. We'll try processing this message again later.`
);
return;
}
log.warn(
`${idLog}: Received ${storyContextLogId} message in 1:1 conversation but no matching story. Dropping.`
);
confirm();
return; return;
} }
if (storyQuote) { if (storyQuote) {
const sendStateByConversationId =
storyQuote.get('sendStateByConversationId') || {};
const sendState = sendStateByConversationId[sender.id];
const storyQuoteIsFromSelf =
storyQuote.get('sourceUuid') ===
window.storage.user.getCheckedUuid().toString();
if (storyQuoteIsFromSelf && !sendState) {
log.warn(
`${idLog}: Received storyContext message but sender was not in sendStateByConversationId. Dropping.`
);
confirm();
return;
}
if (
storyQuoteIsFromSelf &&
sendState.isAllowedToReplyToStory === false &&
isDirectConversation(conversation.attributes)
) {
log.warn(
`${idLog}: Received 1:1 storyContext message but sender is not allowed to reply. Dropping.`
);
confirm();
return;
}
const storyDistributionListId = storyQuote.get( const storyDistributionListId = storyQuote.get(
'storyDistributionListId' 'storyDistributionListId'
); );
@ -2517,7 +2524,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
if (!storyDistribution) { if (!storyDistribution) {
log.warn( log.warn(
`${idLog}: Received storyContext message for story with no associated distribution list. Dropping.` `${idLog}: Received ${storyContextLogId} message for story with no associated distribution list. Dropping.`
); );
confirm(); confirm();
@ -2526,7 +2533,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
if (!storyDistribution.allowsReplies) { if (!storyDistribution.allowsReplies) {
log.warn( log.warn(
`${idLog}: Received storyContext message but distribution list does not allow replies. Dropping.` `${idLog}: Received ${storyContextLogId} message but distribution list does not allow replies. Dropping.`
); );
confirm(); confirm();

View file

@ -0,0 +1,195 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import createDebug from 'debug';
import Long from 'long';
import { Proto, StorageState } from '@signalapp/mock-server';
import * as durations from '../../util/durations';
import { uuidToBytes } from '../../util/uuidToBytes';
import { MY_STORY_ID } from '../../types/Stories';
import { UUID } from '../../types/UUID';
import type { App } from '../playwright';
import { Bootstrap } from '../bootstrap';
export const debug = createDebug('mock:test:edit');
const IdentifierType = Proto.ManifestRecord.Identifier.Type;
const DISTRIBUTION1 = UUID.generate().toString();
const DISTRIBUTION2 = UUID.generate().toString();
describe('story/messaging', function unknownContacts() {
this.timeout(durations.MINUTE);
let bootstrap: Bootstrap;
let app: App;
beforeEach(async () => {
bootstrap = new Bootstrap();
await bootstrap.init();
const { phone, contacts } = bootstrap;
const [first, second] = contacts;
let state = StorageState.getEmpty();
state = state.updateAccount({
profileKey: phone.profileKey.serialize(),
e164: phone.device.number,
givenName: phone.profileName,
hasSetMyStoriesPrivacy: true,
});
// Create empty My Story
state = state.addRecord({
type: IdentifierType.STORY_DISTRIBUTION_LIST,
record: {
storyDistributionList: {
allowsReplies: true,
identifier: uuidToBytes(MY_STORY_ID),
isBlockList: false,
name: MY_STORY_ID,
recipientUuids: [],
},
},
});
// Create two distribution lists corresponding to two contacts
state = state.addRecord({
type: IdentifierType.STORY_DISTRIBUTION_LIST,
record: {
storyDistributionList: {
allowsReplies: true,
identifier: uuidToBytes(DISTRIBUTION1),
isBlockList: false,
name: 'first',
recipientUuids: [first.device.uuid],
},
},
});
state = state.addRecord({
type: IdentifierType.STORY_DISTRIBUTION_LIST,
record: {
storyDistributionList: {
allowsReplies: true,
identifier: uuidToBytes(DISTRIBUTION2),
isBlockList: false,
name: 'second',
recipientUuids: [second.device.uuid],
},
},
});
// Finally whitelist and pin contacts
for (const contact of [first, second]) {
state = state.addContact(contact, {
whitelisted: true,
serviceE164: contact.device.number,
identityKey: contact.publicKey.serialize(),
profileKey: contact.profileKey.serialize(),
givenName: contact.profileName,
});
state = state.pin(contact);
}
await phone.setStorageState(state);
app = await bootstrap.link();
});
afterEach(async function after() {
if (!bootstrap) {
return;
}
if (this.currentTest?.state !== 'passed') {
await bootstrap.saveLogs(app);
}
await app.close();
await bootstrap.teardown();
});
it('allows replies on multiple distribution lists', async () => {
const { phone, desktop, contacts } = bootstrap;
const [first, second] = contacts;
const window = await app.getWindow();
const sentAt = Date.now();
debug('waiting for storage service sync to complete');
await app.waitForStorageService();
debug('sending story sync message');
await phone.sendRaw(
desktop,
{
syncMessage: {
sent: {
timestamp: Long.fromNumber(sentAt),
expirationStartTimestamp: Long.fromNumber(sentAt),
storyMessage: {
textAttachment: {
text: 'hello',
},
allowsReplies: true,
},
storyMessageRecipients: [
{
destinationUuid: first.device.uuid,
distributionListIds: [DISTRIBUTION1],
isAllowedToReply: true,
},
{
destinationUuid: second.device.uuid,
distributionListIds: [DISTRIBUTION2],
isAllowedToReply: true,
},
],
},
},
},
{ timestamp: sentAt }
);
debug('sending story replies');
await first.sendRaw(
desktop,
{
dataMessage: {
body: 'first reply',
storyContext: {
authorUuid: phone.device.uuid,
sentTimestamp: Long.fromNumber(sentAt),
},
timestamp: Long.fromNumber(sentAt + 1),
},
},
{ timestamp: sentAt + 1 }
);
await second.sendRaw(
desktop,
{
dataMessage: {
body: 'second reply',
storyContext: {
authorUuid: phone.device.uuid,
sentTimestamp: Long.fromNumber(sentAt),
},
timestamp: Long.fromNumber(sentAt + 2),
},
},
{ timestamp: sentAt + 2 }
);
const leftPane = window.locator('.left-pane-wrapper');
debug('Finding both replies');
await leftPane
.locator(`[data-testid="${first.device.uuid}"] >> "first reply"`)
.waitFor();
await leftPane
.locator(`[data-testid="${second.device.uuid}"] >> "second reply"`)
.waitFor();
});
});

View file

@ -38,7 +38,7 @@ import {
SignedPreKeys, SignedPreKeys,
} from '../LibSignalStores'; } from '../LibSignalStores';
import { verifySignature } from '../Curve'; import { verifySignature } from '../Curve';
import { strictAssert } from '../util/assert'; import { strictAssert, assertDev } from '../util/assert';
import type { BatcherType } from '../util/batcher'; import type { BatcherType } from '../util/batcher';
import { createBatcher } from '../util/batcher'; import { createBatcher } from '../util/batcher';
import { drop } from '../util/drop'; import { drop } from '../util/drop';
@ -2213,12 +2213,20 @@ export default class MessageReceiver
'handleStoryMessage.destinationUuid' 'handleStoryMessage.destinationUuid'
); );
recipient.distributionListIds?.forEach(listId => { if (recipient.distributionListIds) {
const sentUuids: Set<string> = recipient.distributionListIds.forEach(listId => {
distributionListToSentUuid.get(listId) || new Set(); const sentUuids: Set<string> =
sentUuids.add(normalizedDestinationUuid); distributionListToSentUuid.get(listId) || new Set();
distributionListToSentUuid.set(listId, sentUuids); sentUuids.add(normalizedDestinationUuid);
}); distributionListToSentUuid.set(listId, sentUuids);
});
} else {
assertDev(
false,
`MessageReceiver.handleStoryMessage(${logId}): missing ` +
`distribution list id for: ${destinationUuid}`
);
}
isAllowedToReply.set( isAllowedToReply.set(
normalizedDestinationUuid, normalizedDestinationUuid,

View file

@ -5,22 +5,22 @@ import type { MessageAttributesType } from '../model-types.d';
import type { MessageModel } from '../models/messages'; import type { MessageModel } from '../models/messages';
import type { SignalService as Proto } from '../protobuf'; import type { SignalService as Proto } from '../protobuf';
import * as log from '../logging/log'; import * as log from '../logging/log';
import { find } from './iterables'; import { filter } from './iterables';
import { getContactId } from '../messages/helpers'; import { getContactId } from '../messages/helpers';
import { getTimestampFromLong } from './timestampLongUtils'; import { getTimestampFromLong } from './timestampLongUtils';
export async function findStoryMessage( export async function findStoryMessages(
conversationId: string, conversationId: string,
storyContext?: Proto.DataMessage.IStoryContext storyContext?: Proto.DataMessage.IStoryContext
): Promise<MessageModel | undefined> { ): Promise<Array<MessageModel>> {
if (!storyContext) { if (!storyContext) {
return; return [];
} }
const { authorUuid, sentTimestamp } = storyContext; const { authorUuid, sentTimestamp } = storyContext;
if (!authorUuid || !sentTimestamp) { if (!authorUuid || !sentTimestamp) {
return; return [];
} }
const sentAt = getTimestampFromLong(sentTimestamp); const sentAt = getTimestampFromLong(sentTimestamp);
@ -28,33 +28,37 @@ export async function findStoryMessage(
window.ConversationController.getOurConversationIdOrThrow(); window.ConversationController.getOurConversationIdOrThrow();
const inMemoryMessages = window.MessageController.filterBySentAt(sentAt); const inMemoryMessages = window.MessageController.filterBySentAt(sentAt);
const matchingMessage = find(inMemoryMessages, item => const matchingMessages = [
isStoryAMatch( ...filter(inMemoryMessages, item =>
item.attributes, isStoryAMatch(
conversationId, item.attributes,
ourConversationId, conversationId,
authorUuid, ourConversationId,
sentAt authorUuid,
) sentAt
); )
),
];
if (matchingMessage) { if (matchingMessages.length > 0) {
return matchingMessage; return matchingMessages;
} }
log.info('findStoryMessage: db lookup needed', sentAt); log.info('findStoryMessages: db lookup needed', sentAt);
const messages = await window.Signal.Data.getMessagesBySentAt(sentAt); const messages = await window.Signal.Data.getMessagesBySentAt(sentAt);
const found = messages.find(item => const found = messages.filter(item =>
isStoryAMatch(item, conversationId, ourConversationId, authorUuid, sentAt) isStoryAMatch(item, conversationId, ourConversationId, authorUuid, sentAt)
); );
if (!found) { if (found.length !== 0) {
log.info('findStoryMessage: message not found', sentAt); log.info('findStoryMessages: message not found', sentAt);
return; return [];
} }
const message = window.MessageController.register(found.id, found); const result = found.map(attributes =>
return message; window.MessageController.register(attributes.id, attributes)
);
return result;
} }
function isStoryAMatch( function isStoryAMatch(