Improve story DOE flow

This commit is contained in:
Fedor Indutny 2022-11-28 18:07:26 -08:00 committed by GitHub
parent 5e9744d62a
commit 37d383f344
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 630 additions and 245 deletions

View file

@ -16,6 +16,7 @@ import { sendNormalMessage } from './helpers/sendNormalMessage';
import { sendDirectExpirationTimerUpdate } from './helpers/sendDirectExpirationTimerUpdate'; import { sendDirectExpirationTimerUpdate } from './helpers/sendDirectExpirationTimerUpdate';
import { sendGroupUpdate } from './helpers/sendGroupUpdate'; import { sendGroupUpdate } from './helpers/sendGroupUpdate';
import { sendDeleteForEveryone } from './helpers/sendDeleteForEveryone'; import { sendDeleteForEveryone } from './helpers/sendDeleteForEveryone';
import { sendDeleteStoryForEveryone } from './helpers/sendDeleteStoryForEveryone';
import { sendProfileKey } from './helpers/sendProfileKey'; import { sendProfileKey } from './helpers/sendProfileKey';
import { sendReaction } from './helpers/sendReaction'; import { sendReaction } from './helpers/sendReaction';
import { sendStory } from './helpers/sendStory'; import { sendStory } from './helpers/sendStory';
@ -41,6 +42,7 @@ import type { UUIDStringType } from '../types/UUID';
// these values, you'll likely need to write a database migration. // these values, you'll likely need to write a database migration.
export const conversationQueueJobEnum = z.enum([ export const conversationQueueJobEnum = z.enum([
'DeleteForEveryone', 'DeleteForEveryone',
'DeleteStoryForEveryone',
'DirectExpirationTimerUpdate', 'DirectExpirationTimerUpdate',
'GroupUpdate', 'GroupUpdate',
'NormalMessage', 'NormalMessage',
@ -61,6 +63,25 @@ export type DeleteForEveryoneJobData = z.infer<
typeof deleteForEveryoneJobDataSchema typeof deleteForEveryoneJobDataSchema
>; >;
const deleteStoryForEveryoneJobDataSchema = z.object({
type: z.literal(conversationQueueJobEnum.enum.DeleteStoryForEveryone),
conversationId: z.string(),
storyId: z.string(),
targetTimestamp: z.number(),
updatedStoryRecipients: z
.array(
z.object({
destinationUuid: z.string(),
distributionListIds: z.array(z.string()),
isAllowedToReply: z.boolean(),
})
)
.optional(),
});
export type DeleteStoryForEveryoneJobData = z.infer<
typeof deleteStoryForEveryoneJobDataSchema
>;
const expirationTimerUpdateJobDataSchema = z.object({ const expirationTimerUpdateJobDataSchema = z.object({
type: z.literal(conversationQueueJobEnum.enum.DirectExpirationTimerUpdate), type: z.literal(conversationQueueJobEnum.enum.DirectExpirationTimerUpdate),
conversationId: z.string(), conversationId: z.string(),
@ -120,6 +141,7 @@ export type StoryJobData = z.infer<typeof storyJobDataSchema>;
export const conversationQueueJobDataSchema = z.union([ export const conversationQueueJobDataSchema = z.union([
deleteForEveryoneJobDataSchema, deleteForEveryoneJobDataSchema,
deleteStoryForEveryoneJobDataSchema,
expirationTimerUpdateJobDataSchema, expirationTimerUpdateJobDataSchema,
groupUpdateJobDataSchema, groupUpdateJobDataSchema,
normalMessageSendJobDataSchema, normalMessageSendJobDataSchema,
@ -334,6 +356,9 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
case jobSet.DeleteForEveryone: case jobSet.DeleteForEveryone:
await sendDeleteForEveryone(conversation, jobBundle, data); await sendDeleteForEveryone(conversation, jobBundle, data);
break; break;
case jobSet.DeleteStoryForEveryone:
await sendDeleteStoryForEveryone(conversation, jobBundle, data);
break;
case jobSet.DirectExpirationTimerUpdate: case jobSet.DirectExpirationTimerUpdate:
await sendDirectExpirationTimerUpdate(conversation, jobBundle, data); await sendDirectExpirationTimerUpdate(conversation, jobBundle, data);
break; break;

View file

@ -55,15 +55,22 @@ export async function sendDeleteForEveryone(
targetTimestamp, targetTimestamp,
} = data; } = data;
const logId = `sendDeleteForEveryone(${conversation.idForLogging()}, ${messageId})`;
const message = await getMessageById(messageId); const message = await getMessageById(messageId);
if (!message) { if (!message) {
log.error(`Failed to fetch message ${messageId}. Failing job.`); log.error(`${logId}: Failed to fetch message. Failing job.`);
return; return;
} }
const story = isStory(message.attributes); const story = isStory(message.attributes);
if (story && !isGroupV2(conversation.attributes)) {
log.error(`${logId}: 1-on-1 Story DOE must use its own job. Failing job`);
return;
}
if (!shouldContinue) { if (!shouldContinue) {
log.info('Ran out of time. Giving up on sending delete for everyone'); log.info(`${logId}: Ran out of time. Giving up on sending`);
updateMessageWithFailure(message, [new Error('Ran out of time!')], log); updateMessageWithFailure(message, [new Error('Ran out of time!')], log);
return; return;
} }
@ -73,8 +80,6 @@ export async function sendDeleteForEveryone(
const contentHint = ContentHint.RESENDABLE; const contentHint = ContentHint.RESENDABLE;
const messageIds = [messageId]; const messageIds = [messageId];
const logId = `deleteForEveryone/${conversation.idForLogging()}`;
const deletedForEveryoneSendStatus = message.get( const deletedForEveryoneSendStatus = message.get(
'deletedForEveryoneSendStatus' 'deletedForEveryoneSendStatus'
); );
@ -97,9 +102,8 @@ export async function sendDeleteForEveryone(
'conversationQueue/sendDeleteForEveryone', 'conversationQueue/sendDeleteForEveryone',
async abortSignal => { async abortSignal => {
log.info( log.info(
`Sending deleteForEveryone to conversation ${logId}`, `${logId}: Sending deleteForEveryone with timestamp ${timestamp}` +
`with timestamp ${timestamp}`, `for message ${targetTimestamp}, isStory=${story}`
`for message ${targetTimestamp}`
); );
let profileKey: Uint8Array | undefined; let profileKey: Uint8Array | undefined;

View file

@ -0,0 +1,306 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as Errors from '../../types/errors';
import { getSendOptions } from '../../util/getSendOptions';
import { isDirectConversation, isMe } from '../../util/whatTypeOfConversation';
import { SignalService as Proto } from '../../protobuf';
import {
handleMultipleSendErrors,
maybeExpandErrors,
} from './handleMultipleSendErrors';
import { ourProfileKeyService } from '../../services/ourProfileKey';
import type { ConversationModel } from '../../models/conversations';
import type {
ConversationQueueJobBundle,
DeleteStoryForEveryoneJobData,
} from '../conversationJobQueue';
import { getUntrustedConversationUuids } from './getUntrustedConversationUuids';
import { handleMessageSend } from '../../util/handleMessageSend';
import { isConversationAccepted } from '../../util/isConversationAccepted';
import { isConversationUnregistered } from '../../util/isConversationUnregistered';
import { getMessageById } from '../../messages/getMessageById';
import { isNotNil } from '../../util/isNotNil';
import type { CallbackResultType } from '../../textsecure/Types.d';
import type { MessageModel } from '../../models/messages';
import { SendMessageProtoError } from '../../textsecure/Errors';
import { strictAssert } from '../../util/assert';
import type { LoggerType } from '../../types/Logging';
import { isStory } from '../../messages/helpers';
export async function sendDeleteStoryForEveryone(
ourConversation: ConversationModel,
{
isFinalAttempt,
messaging,
shouldContinue,
timestamp,
timeRemaining,
log,
}: ConversationQueueJobBundle,
data: DeleteStoryForEveryoneJobData
): Promise<void> {
const { storyId, targetTimestamp, updatedStoryRecipients } = data;
const logId = `sendDeleteStoryForEveryone(${storyId})`;
const message = await getMessageById(storyId);
if (!message) {
log.error(`${logId}: Failed to fetch message. Failing job.`);
return;
}
if (!shouldContinue) {
log.info(`${logId}: Ran out of time. Giving up on sending`);
updateMessageWithFailure(message, [new Error('Ran out of time!')], log);
return;
}
strictAssert(
isMe(ourConversation.attributes),
'Story DOE must be sent on our conversaton'
);
strictAssert(isStory(message.attributes), 'Story message must be a story');
const sendType = 'deleteForEveryone';
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
const contentHint = ContentHint.RESENDABLE;
const deletedForEveryoneSendStatus = message.get(
'deletedForEveryoneSendStatus'
);
strictAssert(
deletedForEveryoneSendStatus,
`${logId}: message does not have deletedForEveryoneSendStatus`
);
const recipientIds = Object.entries(deletedForEveryoneSendStatus)
.filter(([_, isSent]) => !isSent)
.map(([conversationId]) => conversationId);
const untrustedUuids = getUntrustedConversationUuids(recipientIds);
if (untrustedUuids.length) {
window.reduxActions.conversations.conversationStoppedByMissingVerification({
conversationId: ourConversation.id,
untrustedUuids,
});
throw new Error(
`Delete for everyone blocked because ${untrustedUuids.length} ` +
'conversation(s) were untrusted. Failing this attempt.'
);
}
const recipientConversations = recipientIds
.map(conversationId => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
log.error(`${logId}: conversation not found for ${conversationId}`);
return undefined;
}
if (!isDirectConversation(conversation.attributes)) {
log.error(`${logId}: conversation ${conversationId} is not direct`);
return undefined;
}
if (!isConversationAccepted(conversation.attributes)) {
log.info(
`${logId}: conversation ${conversation.idForLogging()} ` +
'is not accepted; refusing to send'
);
updateMessageWithFailure(
message,
[new Error('Message request was not accepted')],
log
);
return undefined;
}
if (isConversationUnregistered(conversation.attributes)) {
log.info(
`${logId}: conversation ${conversation.idForLogging()} ` +
'is unregistered; refusing to send'
);
updateMessageWithFailure(
message,
[new Error('Contact no longer has a Signal account')],
log
);
return undefined;
}
if (conversation.isBlocked()) {
log.info(
`${logId}: conversation ${conversation.idForLogging()} ` +
'is blocked; refusing to send'
);
updateMessageWithFailure(
message,
[new Error('Contact is blocked')],
log
);
return undefined;
}
return conversation;
})
.filter(isNotNil);
const hadSuccessfulSends = doesMessageHaveSuccessfulSends(message);
let didSuccessfullySendOne = false;
// Special case - we have no one to send it to so just send the sync message.
if (recipientConversations.length === 0) {
didSuccessfullySendOne = true;
}
const profileKey = await ourProfileKeyService.get();
await Promise.all(
recipientConversations.map(conversation => {
return conversation.queueJob(
'conversationQueue/sendStoryDeleteForEveryone',
async () => {
log.info(
`${logId}: Sending deleteStoryForEveryone with timestamp ${timestamp}`
);
const sendOptions = await getSendOptions(conversation.attributes, {
story: true,
});
try {
await handleMessageSend(
messaging.sendMessageToIdentifier({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
identifier: conversation.getSendTarget()!,
messageText: undefined,
attachments: [],
deletedForEveryoneTimestamp: targetTimestamp,
timestamp,
expireTimer: undefined,
contentHint,
groupId: undefined,
profileKey: conversation.get('profileSharing')
? profileKey
: undefined,
options: sendOptions,
urgent: true,
story: true,
}),
{
messageIds: [storyId],
sendType,
}
);
didSuccessfullySendOne = true;
await updateMessageWithSuccessfulSends(message, {
successfulIdentifiers: [conversation.id],
});
} catch (error: unknown) {
if (error instanceof SendMessageProtoError) {
await updateMessageWithSuccessfulSends(message, error);
}
const errors = maybeExpandErrors(error);
await handleMultipleSendErrors({
errors,
isFinalAttempt,
log,
markFailed: () => updateMessageWithFailure(message, errors, log),
timeRemaining,
toThrow: error,
});
}
}
);
})
);
// Send sync message exactly once per job. If any of the sends are successful
// and we didn't send the DOE itself before - it is a good time to send the
// sync message.
if (!hadSuccessfulSends && didSuccessfullySendOne) {
log.info(`${logId}: Sending sync message`);
const options = await getSendOptions(ourConversation.attributes, {
syncMessage: true,
});
const destinationUuid = ourConversation
.getCheckedUuid('deleteStoryForEveryone')
.toString();
// Sync message for other devices
await handleMessageSend(
messaging.sendSyncMessage({
destination: undefined,
destinationUuid,
storyMessageRecipients: updatedStoryRecipients,
expirationStartTimestamp: null,
isUpdate: true,
options,
timestamp: message.get('timestamp'),
urgent: false,
}),
{ messageIds: [storyId], sendType }
);
}
}
function doesMessageHaveSuccessfulSends(message: MessageModel): boolean {
const map = message.get('deletedForEveryoneSendStatus') ?? {};
return Object.values(map).some(value => value === true);
}
async function updateMessageWithSuccessfulSends(
message: MessageModel,
result?: CallbackResultType | SendMessageProtoError
): Promise<void> {
if (!result) {
message.set({
deletedForEveryoneSendStatus: {},
deletedForEveryoneFailed: undefined,
});
await window.Signal.Data.saveMessage(message.attributes, {
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
});
return;
}
const deletedForEveryoneSendStatus = {
...message.get('deletedForEveryoneSendStatus'),
};
result.successfulIdentifiers?.forEach(identifier => {
const conversation = window.ConversationController.get(identifier);
if (!conversation) {
return;
}
deletedForEveryoneSendStatus[conversation.id] = true;
});
message.set({
deletedForEveryoneSendStatus,
deletedForEveryoneFailed: undefined,
});
await window.Signal.Data.saveMessage(message.attributes, {
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
});
}
async function updateMessageWithFailure(
message: MessageModel,
errors: ReadonlyArray<unknown>,
log: LoggerType
): Promise<void> {
log.error(
'updateMessageWithFailure: Setting this set of errors',
errors.map(Errors.toLogFormat)
);
message.set({ deletedForEveryoneFailed: true });
await window.Signal.Data.saveMessage(message.attributes, {
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
});
}

View file

@ -13,7 +13,6 @@ import type {
} from '../conversationJobQueue'; } from '../conversationJobQueue';
import type { LoggerType } from '../../types/Logging'; import type { LoggerType } from '../../types/Logging';
import type { MessageModel } from '../../models/messages'; import type { MessageModel } from '../../models/messages';
import type { SenderKeyInfoType } from '../../model-types.d';
import type { import type {
SendState, SendState,
SendStateByConversationId, SendStateByConversationId,
@ -25,6 +24,7 @@ import {
} from '../../messages/MessageSendState'; } from '../../messages/MessageSendState';
import type { UUIDStringType } from '../../types/UUID'; import type { UUIDStringType } from '../../types/UUID';
import * as Errors from '../../types/errors'; import * as Errors from '../../types/errors';
import type { StoryMessageRecipientsType } from '../../types/Stories';
import dataInterface from '../../sql/Client'; import dataInterface from '../../sql/Client';
import { SignalService as Proto } from '../../protobuf'; import { SignalService as Proto } from '../../protobuf';
import { getMessagesById } from '../../messages/getMessagesById'; import { getMessagesById } from '../../messages/getMessagesById';
@ -35,9 +35,9 @@ import {
import { handleMessageSend } from '../../util/handleMessageSend'; import { handleMessageSend } from '../../util/handleMessageSend';
import { handleMultipleSendErrors } from './handleMultipleSendErrors'; import { handleMultipleSendErrors } from './handleMultipleSendErrors';
import { isGroupV2, isMe } from '../../util/whatTypeOfConversation'; import { isGroupV2, isMe } from '../../util/whatTypeOfConversation';
import { isNotNil } from '../../util/isNotNil';
import { ourProfileKeyService } from '../../services/ourProfileKey'; import { ourProfileKeyService } from '../../services/ourProfileKey';
import { sendContentMessageToGroup } from '../../util/sendToGroup'; import { sendContentMessageToGroup } from '../../util/sendToGroup';
import { distributionListToSendTarget } from '../../util/distributionListToSendTarget';
import { SendMessageChallengeError } from '../../textsecure/Errors'; import { SendMessageChallengeError } from '../../textsecure/Errors';
export async function sendStory( export async function sendStory(
@ -283,8 +283,6 @@ export async function sendStory(
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
const recipientsSet = new Set(pendingSendRecipientIds);
const sendOptions = await getSendOptionsForRecipients( const sendOptions = await getSendOptionsForRecipients(
pendingSendRecipientIds, pendingSendRecipientIds,
{ story: true } { story: true }
@ -303,28 +301,11 @@ export async function sendStory(
isGroupV2(conversation.attributes) || isGroupV2(conversation.attributes) ||
Boolean(distributionList?.allowsReplies); Boolean(distributionList?.allowsReplies);
let inMemorySenderKeyInfo = distributionList?.senderKeyInfo;
const sendTarget = distributionList const sendTarget = distributionList
? { ? distributionListToSendTarget(
getGroupId: () => undefined, distributionList,
getMembers: () => pendingSendRecipientIds
pendingSendRecipientIds )
.map(uuid => window.ConversationController.get(uuid))
.filter(isNotNil),
hasMember: (uuid: UUIDStringType) => recipientsSet.has(uuid),
idForLogging: () => `dl(${receiverId})`,
isGroupV2: () => true,
isValid: () => true,
getSenderKeyInfo: () => inMemorySenderKeyInfo,
saveSenderKeyInfo: async (senderKeyInfo: SenderKeyInfoType) => {
inMemorySenderKeyInfo = senderKeyInfo;
await dataInterface.modifyStoryDistribution({
...distributionList,
senderKeyInfo,
});
},
}
: conversation.toSenderKeyTarget(); : conversation.toSenderKeyTarget();
const contentMessage = new Proto.Content(); const contentMessage = new Proto.Content();
@ -530,11 +511,7 @@ export async function sendStory(
}); });
// Build up the sync message's storyMessageRecipients and send it // Build up the sync message's storyMessageRecipients and send it
const storyMessageRecipients: Array<{ const storyMessageRecipients: StoryMessageRecipientsType = [];
destinationUuid: string;
distributionListIds: Array<string>;
isAllowedToReply: boolean;
}> = [];
recipientsByUuid.forEach((distributionListIds, destinationUuid) => { recipientsByUuid.forEach((distributionListIds, destinationUuid) => {
storyMessageRecipients.push({ storyMessageRecipients.push({
destinationUuid, destinationUuid,

1
ts/model-types.d.ts vendored
View file

@ -153,6 +153,7 @@ export type MessageAttributesType = {
storyDistributionListId?: string; storyDistributionListId?: string;
storyId?: string; storyId?: string;
storyReplyContext?: StoryReplyContextType; storyReplyContext?: StoryReplyContextType;
storyRecipientsVersion?: number;
supportedVersionAtReceive?: unknown; supportedVersionAtReceive?: unknown;
synced?: boolean; synced?: boolean;
unidentifiedDeliveryReceived?: boolean; unidentifiedDeliveryReceived?: boolean;

View file

@ -109,6 +109,7 @@ export function getStoryDataFromMessageAttributes(
'source', 'source',
'sourceUuid', 'sourceUuid',
'storyDistributionListId', 'storyDistributionListId',
'storyRecipientsVersion',
'timestamp', 'timestamp',
'type', 'type',
]), ]),

View file

@ -77,6 +77,7 @@ export type StoryDataType = {
| 'storyDistributionListId' | 'storyDistributionListId'
| 'timestamp' | 'timestamp'
| 'type' | 'type'
| 'storyRecipientsVersion'
> & { > & {
// don't want the fields to be optional as in MessageAttributesType // don't want the fields to be optional as in MessageAttributesType
expireTimer: DurationInSeconds | undefined; expireTimer: DurationInSeconds | undefined;

View file

@ -1431,11 +1431,7 @@ export default class MessageSender {
urgent: boolean; urgent: boolean;
options?: SendOptionsType; options?: SendOptionsType;
storyMessage?: Proto.StoryMessage; storyMessage?: Proto.StoryMessage;
storyMessageRecipients?: Array<{ storyMessageRecipients?: ReadonlyArray<Proto.SyncMessage.Sent.IStoryMessageRecipient>;
destinationUuid: string;
distributionListIds: Array<string>;
isAllowedToReply: boolean;
}>;
}>): Promise<CallbackResultType> { }>): Promise<CallbackResultType> {
const myUuid = window.textsecure.storage.user.getCheckedUuid(); const myUuid = window.textsecure.storage.user.getCheckedUuid();
@ -1461,17 +1457,7 @@ export default class MessageSender {
sentMessage.storyMessage = storyMessage; sentMessage.storyMessage = storyMessage;
} }
if (storyMessageRecipients) { if (storyMessageRecipients) {
sentMessage.storyMessageRecipients = storyMessageRecipients.map( sentMessage.storyMessageRecipients = storyMessageRecipients.slice();
recipient => {
const storyMessageRecipient =
new Proto.SyncMessage.Sent.StoryMessageRecipient();
storyMessageRecipient.destinationUuid = recipient.destinationUuid;
storyMessageRecipient.distributionListIds =
recipient.distributionListIds;
storyMessageRecipient.isAllowedToReply = recipient.isAllowedToReply;
return storyMessageRecipient;
}
);
} }
if (isUpdate) { if (isUpdate) {

View file

@ -166,3 +166,9 @@ export enum ResolvedSendStatus {
Sending = 'Sending', Sending = 'Sending',
Sent = 'Sent', Sent = 'Sent',
} }
export type StoryMessageRecipientsType = Array<{
destinationUuid: string;
distributionListIds: Array<string>;
isAllowedToReply: boolean;
}>;

View file

@ -16,11 +16,21 @@ export enum UUIDKind {
export const UUID_BYTE_SIZE = 16; export const UUID_BYTE_SIZE = 16;
export const isValidUuid = (value: unknown): value is UUIDStringType => const UUID_REGEXP =
typeof value === 'string' && /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i;
/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i.test(
value export const isValidUuid = (value: unknown): value is UUIDStringType => {
); if (typeof value !== 'string') {
return false;
}
// Zero UUID is a valid uuid.
if (value === '00000000-0000-0000-0000-000000000000') {
return true;
}
return UUID_REGEXP.test(value);
};
export class UUID { export class UUID {
constructor(protected readonly value: string) { constructor(protected readonly value: string) {

View file

@ -2,12 +2,25 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { noop } from 'lodash'; import { noop } from 'lodash';
import type { ConversationQueueJobData } from '../jobs/conversationJobQueue';
import type { StoryDataType } from '../state/ducks/stories'; import type { StoryDataType } from '../state/ducks/stories';
import * as Errors from '../types/errors';
import type { StoryMessageRecipientsType } from '../types/Stories';
import * as log from '../logging/log';
import { DAY } from './durations'; import { DAY } from './durations';
import { StoryRecipientUpdateEvent } from '../textsecure/messageReceiverEvents'; import { StoryRecipientUpdateEvent } from '../textsecure/messageReceiverEvents';
import { getSendOptions } from './getSendOptions'; import {
conversationJobQueue,
conversationQueueJobEnum,
} from '../jobs/conversationJobQueue';
import { onStoryRecipientUpdate } from './onStoryRecipientUpdate'; import { onStoryRecipientUpdate } from './onStoryRecipientUpdate';
import { sendDeleteForEveryoneMessage } from './sendDeleteForEveryoneMessage'; import { sendDeleteForEveryoneMessage } from './sendDeleteForEveryoneMessage';
import { isGroupV2 } from './whatTypeOfConversation';
import { getMessageById } from '../messages/getMessageById';
import { strictAssert } from './assert';
import { repeat, zipObject } from './iterables';
import { isOlderThan } from './timestamp';
export async function deleteStoryForEveryone( export async function deleteStoryForEveryone(
stories: ReadonlyArray<StoryDataType>, stories: ReadonlyArray<StoryDataType>,
@ -17,8 +30,31 @@ export async function deleteStoryForEveryone(
return; return;
} }
// Group stories are deleted as regular messages.
const sourceConversation = window.ConversationController.get(
story.conversationId
);
if (sourceConversation && isGroupV2(sourceConversation.attributes)) {
sendDeleteForEveryoneMessage(sourceConversation.attributes, {
deleteForEveryoneDuration: DAY,
id: story.messageId,
timestamp: story.timestamp,
});
return;
}
const logId = `deleteStoryForEveryone(${story.messageId})`;
const message = await getMessageById(story.messageId);
if (!message) {
throw new Error('Story not found');
}
if (isOlderThan(story.timestamp, DAY)) {
throw new Error('Cannot send DOE for a story older than one day');
}
const conversationIds = new Set(Object.keys(story.sendStateByConversationId)); const conversationIds = new Set(Object.keys(story.sendStateByConversationId));
const updatedStoryRecipients = new Map< const newStoryRecipients = new Map<
string, string,
{ {
distributionListIds: Set<string>; distributionListIds: Set<string>;
@ -32,6 +68,30 @@ export async function deleteStoryForEveryone(
// Remove ourselves from the DOE. // Remove ourselves from the DOE.
conversationIds.delete(ourConversation.id); conversationIds.delete(ourConversation.id);
// `updatedStoryRecipients` is used to build `storyMessageRecipients` for
// a sync message. Put all affected destinationUuids early on so that if
// there are no other distribution lists for them - we'd still include an
// empty list.
Object.entries(story.sendStateByConversationId).forEach(
([recipientId, sendState]) => {
if (recipientId === ourConversation.id) {
return;
}
const destinationUuid =
window.ConversationController.get(recipientId)?.get('uuid');
if (!destinationUuid) {
return;
}
newStoryRecipients.set(destinationUuid, {
distributionListIds: new Set(),
isAllowedToReply: sendState.isAllowedToReplyToStory !== false,
});
}
);
// Find stories that were sent to other distribution lists so that we don't // Find stories that were sent to other distribution lists so that we don't
// send a DOE request to the members of those lists. // send a DOE request to the members of those lists.
stories.forEach(item => { stories.forEach(item => {
@ -62,120 +122,95 @@ export async function deleteStoryForEveryone(
return; return;
} }
const distributionListIds =
updatedStoryRecipients.get(destinationUuid)?.distributionListIds ||
new Set();
// These are the remaining distribution list ids that the user has
// access to.
updatedStoryRecipients.set(destinationUuid, {
distributionListIds: item.storyDistributionListId
? new Set([...distributionListIds, item.storyDistributionListId])
: distributionListIds,
isAllowedToReply:
sendStateByConversationId[conversationId].isAllowedToReplyToStory !==
false,
});
// Remove this conversationId so we don't send the DOE to those that // Remove this conversationId so we don't send the DOE to those that
// still have access. // still have access.
conversationIds.delete(conversationId); conversationIds.delete(conversationId);
// Build remaining distribution list ids that the user still has
// access to.
if (item.storyDistributionListId === undefined) {
return;
}
// Build complete list of new story recipients (not counting ones that
// are in the deleted story).
let recipient = newStoryRecipients.get(destinationUuid);
if (!recipient) {
const isAllowedToReply =
sendStateByConversationId[conversationId].isAllowedToReplyToStory;
recipient = {
distributionListIds: new Set(),
isAllowedToReply: isAllowedToReply !== false,
};
newStoryRecipients.set(destinationUuid, recipient);
}
recipient.distributionListIds.add(item.storyDistributionListId);
}); });
}); });
// Include the sync message with the updated storyMessageRecipients list
const sender = window.textsecure.messaging;
strictAssert(sender, 'messaging has to be initialized');
const newStoryMessageRecipients: StoryMessageRecipientsType = [];
newStoryRecipients.forEach((recipientData, destinationUuid) => {
newStoryMessageRecipients.push({
destinationUuid,
distributionListIds: Array.from(recipientData.distributionListIds),
isAllowedToReply: recipientData.isAllowedToReply,
});
});
const destinationUuid = ourConversation
.getCheckedUuid('deleteStoryForEveryone')
.toString();
log.info(`${logId}: sending DOE to ${conversationIds.size} conversations`);
message.set({
deletedForEveryoneSendStatus: zipObject(conversationIds, repeat(false)),
});
// Send the DOE // Send the DOE
conversationIds.forEach(cid => { log.info(`${logId}: enqueing DeleteStoryForEveryone`);
// Don't DOE yourself!
if (cid === ourConversation.id) {
return;
}
const conversation = window.ConversationController.get(cid); try {
const jobData: ConversationQueueJobData = {
type: conversationQueueJobEnum.enum.DeleteStoryForEveryone,
conversationId: ourConversation.id,
storyId: story.messageId,
targetTimestamp: story.timestamp,
updatedStoryRecipients: newStoryMessageRecipients,
};
await conversationJobQueue.add(jobData, async jobToInsert => {
log.info(`${logId}: Deleting message with job ${jobToInsert.id}`);
if (!conversation) { await window.Signal.Data.saveMessage(message.attributes, {
return; jobToInsert,
} ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
sendDeleteForEveryoneMessage(conversation.attributes, {
deleteForEveryoneDuration: DAY,
id: story.messageId,
timestamp: story.timestamp,
});
});
// If it's the last story sent to a distribution list we don't have to send
// the sync message, but to be consistent let's build up the updated
// storyMessageRecipients and send the sync message.
if (!updatedStoryRecipients.size) {
Object.entries(story.sendStateByConversationId).forEach(
([recipientId, sendState]) => {
if (recipientId === ourConversation.id) {
return;
}
const destinationUuid =
window.ConversationController.get(recipientId)?.get('uuid');
if (!destinationUuid) {
return;
}
updatedStoryRecipients.set(destinationUuid, {
distributionListIds: new Set(),
isAllowedToReply: sendState.isAllowedToReplyToStory !== false,
});
}
);
}
// Send the sync message with the updated storyMessageRecipients list
const sender = window.textsecure.messaging;
if (sender) {
const options = await getSendOptions(ourConversation.attributes, {
syncMessage: true,
});
const storyMessageRecipients: Array<{
destinationUuid: string;
distributionListIds: Array<string>;
isAllowedToReply: boolean;
}> = [];
updatedStoryRecipients.forEach((recipientData, destinationUuid) => {
storyMessageRecipients.push({
destinationUuid,
distributionListIds: Array.from(recipientData.distributionListIds),
isAllowedToReply: recipientData.isAllowedToReply,
}); });
}); });
} catch (error) {
const destinationUuid = ourConversation.get('uuid'); log.error(
`${logId}: Failed to queue delete for everyone`,
if (!destinationUuid) { Errors.toLogFormat(error)
return;
}
// Sync message for other devices
sender.sendSyncMessage({
destination: undefined,
destinationUuid,
storyMessageRecipients,
expirationStartTimestamp: null,
isUpdate: true,
options,
timestamp: story.timestamp,
urgent: false,
});
// Sync message for Desktop
const ev = new StoryRecipientUpdateEvent(
{
destinationUuid,
timestamp: story.timestamp,
storyMessageRecipients,
},
noop
); );
onStoryRecipientUpdate(ev); throw error;
} }
log.info(`${logId}: emulating sync message event`);
// Emulate message for Desktop (this will call deleteForEveryone())
const ev = new StoryRecipientUpdateEvent(
{
destinationUuid,
timestamp: story.timestamp,
storyMessageRecipients: newStoryMessageRecipients,
},
noop
);
onStoryRecipientUpdate(ev);
} }

View file

@ -0,0 +1,38 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { UUIDStringType } from '../types/UUID';
import type { SenderKeyInfoType } from '../model-types.d';
import dataInterface from '../sql/Client';
import type { StoryDistributionType } from '../sql/Interface';
import type { SenderKeyTargetType } from './sendToGroup';
import { isNotNil } from './isNotNil';
export function distributionListToSendTarget(
distributionList: StoryDistributionType,
pendingSendRecipientIds: ReadonlyArray<string>
): SenderKeyTargetType {
let inMemorySenderKeyInfo = distributionList?.senderKeyInfo;
const recipientsSet = new Set(pendingSendRecipientIds);
return {
getGroupId: () => undefined,
getMembers: () =>
pendingSendRecipientIds
.map(uuid => window.ConversationController.get(uuid))
.filter(isNotNil),
hasMember: (uuid: UUIDStringType) => recipientsSet.has(uuid),
idForLogging: () => `dl(${distributionList.id})`,
isGroupV2: () => true,
isValid: () => true,
getSenderKeyInfo: () => inMemorySenderKeyInfo,
saveSenderKeyInfo: async (senderKeyInfo: SenderKeyInfoType) => {
inMemorySenderKeyInfo = senderKeyInfo;
await dataInterface.modifyStoryDistribution({
...distributionList,
senderKeyInfo,
});
},
};
}

View file

@ -8,10 +8,7 @@ import * as log from '../logging/log';
import { Deletes } from '../messageModifiers/Deletes'; import { Deletes } from '../messageModifiers/Deletes';
import { SendStatus } from '../messages/MessageSendState'; import { SendStatus } from '../messages/MessageSendState';
import { deleteForEveryone } from './deleteForEveryone'; import { deleteForEveryone } from './deleteForEveryone';
import { import { getConversationIdForLogging } from './idForLogging';
getConversationIdForLogging,
getMessageIdForLogging,
} from './idForLogging';
import { isStory } from '../state/selectors/message'; import { isStory } from '../state/selectors/message';
import { normalizeUuid } from './normalizeUuid'; import { normalizeUuid } from './normalizeUuid';
import { queueUpdateMessage } from './messageBatcher'; import { queueUpdateMessage } from './messageBatcher';
@ -25,8 +22,10 @@ export async function onStoryRecipientUpdate(
const conversation = window.ConversationController.get(destinationUuid); const conversation = window.ConversationController.get(destinationUuid);
const logId = `onStoryRecipientUpdate(${destinationUuid}, ${timestamp})`;
if (!conversation) { if (!conversation) {
log.info(`onStoryRecipientUpdate no conversation for ${destinationUuid}`); log.info(`${logId}: no conversation`);
return; return;
} }
@ -37,20 +36,16 @@ export async function onStoryRecipientUpdate(
); );
if (!targetConversation) { if (!targetConversation) {
log.info('onStoryRecipientUpdate !targetConversation', { log.info(`${logId}: no targetConversation`);
destinationUuid,
timestamp,
});
return; return;
} }
targetConversation.queueJob('onStoryRecipientUpdate', async () => { targetConversation.queueJob(logId, async () => {
log.info('onStoryRecipientUpdate updating', timestamp); log.info(`${logId}: updating`);
// Build up some maps for fast/easy lookups // Build up some maps for fast/easy lookups
const isAllowedToReply = new Map<string, boolean>(); const isAllowedToReply = new Map<string, boolean>();
const conversationIdToDistributionListIds = new Map<string, Set<string>>(); const distributionListIdToConversationIds = new Map<string, Set<string>>();
data.storyMessageRecipients.forEach(item => { data.storyMessageRecipients.forEach(item => {
const convo = window.ConversationController.get(item.destinationUuid); const convo = window.ConversationController.get(item.destinationUuid);
@ -58,14 +53,16 @@ export async function onStoryRecipientUpdate(
return; return;
} }
conversationIdToDistributionListIds.set( for (const rawUuid of item.distributionListIds) {
convo.id, const uuid = normalizeUuid(rawUuid, `${logId}.distributionListId`);
new Set(
item.distributionListIds.map(uuid => const existing = distributionListIdToConversationIds.get(uuid);
normalizeUuid(uuid, 'onStoryRecipientUpdate.distributionListId') if (existing === undefined) {
) distributionListIdToConversationIds.set(uuid, new Set([convo.id]));
) } else {
); existing.add(convo.id);
}
}
isAllowedToReply.set(convo.id, item.isAllowedToReply !== false); isAllowedToReply.set(convo.id, item.isAllowedToReply !== false);
}); });
@ -87,55 +84,60 @@ export async function onStoryRecipientUpdate(
return false; return false;
} }
const newConversationIds =
distributionListIdToConversationIds.get(storyDistributionListId) ??
new Set();
const nextSendStateByConversationId = { const nextSendStateByConversationId = {
...sendStateByConversationId, ...sendStateByConversationId,
}; };
conversationIdToDistributionListIds.forEach( // Find conversation ids present in the local send state, but missing
(distributionListIds, conversationId) => { // in the remote state, and remove them from the local state.
const hasDistributionListId = distributionListIds.has( for (const oldId of Object.keys(sendStateByConversationId)) {
storyDistributionListId if (!newConversationIds.has(oldId)) {
); const recipient = window.ConversationController.get(oldId);
const recipient = window.ConversationController.get(conversationId); const recipientLogId = recipient
const conversationIdForLogging = recipient
? getConversationIdForLogging(recipient.attributes) ? getConversationIdForLogging(recipient.attributes)
: conversationId; : oldId;
if ( log.info(`${logId}: removing`, {
hasDistributionListId && recipient: recipientLogId,
!sendStateByConversationId[conversationId] messageId: item.id,
) { storyDistributionListId,
log.info('onStoryRecipientUpdate adding', { });
conversationId: conversationIdForLogging, delete nextSendStateByConversationId[oldId];
messageId: getMessageIdForLogging(item),
storyDistributionListId,
});
nextSendStateByConversationId[conversationId] = {
isAllowedToReplyToStory: Boolean(
isAllowedToReply.get(conversationId)
),
status: SendStatus.Sent,
updatedAt: now,
};
} else if (
sendStateByConversationId[conversationId] &&
!hasDistributionListId
) {
log.info('onStoryRecipientUpdate removing', {
conversationId: conversationIdForLogging,
messageId: getMessageIdForLogging(item),
storyDistributionListId,
});
delete nextSendStateByConversationId[conversationId];
}
} }
); }
// Find conversation ids present in the remote send state, but missing in
// the local send state, and add them to the local state.
for (const newId of newConversationIds) {
if (sendStateByConversationId[newId] === undefined) {
const recipient = window.ConversationController.get(newId);
const recipientLogId = recipient
? getConversationIdForLogging(recipient.attributes)
: newId;
log.info(`${logId}: adding`, {
recipient: recipientLogId,
messageId: item.id,
storyDistributionListId,
});
nextSendStateByConversationId[newId] = {
isAllowedToReplyToStory: Boolean(isAllowedToReply.get(newId)),
status: SendStatus.Sent,
updatedAt: now,
};
}
}
if (isEqual(sendStateByConversationId, nextSendStateByConversationId)) { if (isEqual(sendStateByConversationId, nextSendStateByConversationId)) {
log.info( log.info(`${logId}: sendStateByConversationId does not need update`, {
'onStoryRecipientUpdate: sendStateByConversationId does not need update' messageId: item.id,
); });
return true; return true;
} }
@ -150,8 +152,8 @@ export async function onStoryRecipientUpdate(
(sendStateConversationIds.size === 1 && (sendStateConversationIds.size === 1 &&
sendStateConversationIds.has(ourConversationId)) sendStateConversationIds.has(ourConversationId))
) { ) {
log.info('onStoryRecipientUpdate DOE', { log.info(`${logId} DOE`, {
messageId: getMessageIdForLogging(item), messageId: item.id,
storyDistributionListId, storyDistributionListId,
}); });
const delAttributes: DeleteAttributesType = { const delAttributes: DeleteAttributesType = {

View file

@ -40,8 +40,7 @@ export async function sendDeleteForEveryoneMessage(
if (!message) { if (!message) {
throw new Error('sendDeleteForEveryoneMessage: Cannot find message!'); throw new Error('sendDeleteForEveryoneMessage: Cannot find message!');
} }
const messageModel = window.MessageController.register(messageId, message); const idForLogging = getMessageIdForLogging(message.attributes);
const idForLogging = getMessageIdForLogging(messageModel.attributes);
const timestamp = Date.now(); const timestamp = Date.now();
const maxDuration = deleteForEveryoneDuration || THREE_HOURS; const maxDuration = deleteForEveryoneDuration || THREE_HOURS;
@ -49,7 +48,7 @@ export async function sendDeleteForEveryoneMessage(
throw new Error(`Cannot send DOE for a message older than ${maxDuration}`); throw new Error(`Cannot send DOE for a message older than ${maxDuration}`);
} }
messageModel.set({ message.set({
deletedForEveryoneSendStatus: zipObject( deletedForEveryoneSendStatus: zipObject(
getRecipientConversationIds(conversationAttributes), getRecipientConversationIds(conversationAttributes),
repeat(false) repeat(false)
@ -79,7 +78,7 @@ export async function sendDeleteForEveryoneMessage(
`sendDeleteForEveryoneMessage: Deleting message ${idForLogging} ` + `sendDeleteForEveryoneMessage: Deleting message ${idForLogging} ` +
`in conversation ${conversationIdForLogging} with job ${jobToInsert.id}` `in conversation ${conversationIdForLogging} with job ${jobToInsert.id}`
); );
await window.Signal.Data.saveMessage(messageModel.attributes, { await window.Signal.Data.saveMessage(message.attributes, {
jobToInsert, jobToInsert,
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
}); });
@ -97,5 +96,5 @@ export async function sendDeleteForEveryoneMessage(
serverTimestamp: Date.now(), serverTimestamp: Date.now(),
fromId: window.ConversationController.getOurConversationIdOrThrow(), fromId: window.ConversationController.getOurConversationIdOrThrow(),
}); });
await deleteForEveryone(messageModel, deleteModel); await deleteForEveryone(message, deleteModel);
} }

View file

@ -15,7 +15,7 @@ import { areAllErrorsUnregistered } from '../jobs/helpers/areAllErrorsUnregister
export async function wrapWithSyncMessageSend({ export async function wrapWithSyncMessageSend({
conversation, conversation,
logId, logId: parentLogId,
messageIds, messageIds,
send, send,
sendType, sendType,
@ -28,11 +28,10 @@ export async function wrapWithSyncMessageSend({
sendType: SendTypesType; sendType: SendTypesType;
timestamp: number; timestamp: number;
}): Promise<void> { }): Promise<void> {
const logId = `wrapWithSyncMessageSend(${parentLogId}, ${timestamp})`;
const sender = window.textsecure.messaging; const sender = window.textsecure.messaging;
if (!sender) { if (!sender) {
throw new Error( throw new Error(`${logId}: textsecure.messaging is not available!`);
`wrapWithSyncMessageSend/${logId}: textsecure.messaging is not available!`
);
} }
let response: CallbackResultType | undefined; let response: CallbackResultType | undefined;
@ -52,17 +51,13 @@ export async function wrapWithSyncMessageSend({
if (thrown instanceof Error) { if (thrown instanceof Error) {
error = thrown; error = thrown;
} else { } else {
log.error( log.error(`${logId}: Thrown value was not an Error, returning early`);
`wrapWithSyncMessageSend/${logId}: Thrown value was not an Error, returning early`
);
throw error; throw error;
} }
} }
if (!response && !error) { if (!response && !error) {
throw new Error( throw new Error(`${logId}: message send didn't return result or error!`);
`wrapWithSyncMessageSend/${logId}: message send didn't return result or error!`
);
} }
const dataMessage = const dataMessage =
@ -71,11 +66,9 @@ export async function wrapWithSyncMessageSend({
if (didSuccessfullySendOne) { if (didSuccessfullySendOne) {
if (!dataMessage) { if (!dataMessage) {
log.error( log.error(`${logId}: dataMessage was not returned by send!`);
`wrapWithSyncMessageSend/${logId}: dataMessage was not returned by send!`
);
} else { } else {
log.info(`wrapWithSyncMessageSend/${logId}: Sending sync message...`); log.info(`${logId}: Sending sync message... `);
const ourConversation = const ourConversation =
window.ConversationController.getOurConversationOrThrow(); window.ConversationController.getOurConversationOrThrow();
const options = await getSendOptions(ourConversation.attributes, { const options = await getSendOptions(ourConversation.attributes, {
@ -99,7 +92,8 @@ export async function wrapWithSyncMessageSend({
if (error instanceof Error) { if (error instanceof Error) {
if (areAllErrorsUnregistered(conversation.attributes, error)) { if (areAllErrorsUnregistered(conversation.attributes, error)) {
log.info( log.info(
`wrapWithSyncMessageSend/${logId}: Group send failures were all UnregisteredUserError, returning succcessfully.` `${logId}: Group send failures were all UnregisteredUserError, ` +
'returning succcessfully.'
); );
return; return;
} }