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 { sendGroupUpdate } from './helpers/sendGroupUpdate';
import { sendDeleteForEveryone } from './helpers/sendDeleteForEveryone';
import { sendDeleteStoryForEveryone } from './helpers/sendDeleteStoryForEveryone';
import { sendProfileKey } from './helpers/sendProfileKey';
import { sendReaction } from './helpers/sendReaction';
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.
export const conversationQueueJobEnum = z.enum([
'DeleteForEveryone',
'DeleteStoryForEveryone',
'DirectExpirationTimerUpdate',
'GroupUpdate',
'NormalMessage',
@ -61,6 +63,25 @@ export type DeleteForEveryoneJobData = z.infer<
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({
type: z.literal(conversationQueueJobEnum.enum.DirectExpirationTimerUpdate),
conversationId: z.string(),
@ -120,6 +141,7 @@ export type StoryJobData = z.infer<typeof storyJobDataSchema>;
export const conversationQueueJobDataSchema = z.union([
deleteForEveryoneJobDataSchema,
deleteStoryForEveryoneJobDataSchema,
expirationTimerUpdateJobDataSchema,
groupUpdateJobDataSchema,
normalMessageSendJobDataSchema,
@ -334,6 +356,9 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
case jobSet.DeleteForEveryone:
await sendDeleteForEveryone(conversation, jobBundle, data);
break;
case jobSet.DeleteStoryForEveryone:
await sendDeleteStoryForEveryone(conversation, jobBundle, data);
break;
case jobSet.DirectExpirationTimerUpdate:
await sendDirectExpirationTimerUpdate(conversation, jobBundle, data);
break;

View file

@ -55,15 +55,22 @@ export async function sendDeleteForEveryone(
targetTimestamp,
} = data;
const logId = `sendDeleteForEveryone(${conversation.idForLogging()}, ${messageId})`;
const message = await getMessageById(messageId);
if (!message) {
log.error(`Failed to fetch message ${messageId}. Failing job.`);
log.error(`${logId}: Failed to fetch message. Failing job.`);
return;
}
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) {
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);
return;
}
@ -73,8 +80,6 @@ export async function sendDeleteForEveryone(
const contentHint = ContentHint.RESENDABLE;
const messageIds = [messageId];
const logId = `deleteForEveryone/${conversation.idForLogging()}`;
const deletedForEveryoneSendStatus = message.get(
'deletedForEveryoneSendStatus'
);
@ -97,9 +102,8 @@ export async function sendDeleteForEveryone(
'conversationQueue/sendDeleteForEveryone',
async abortSignal => {
log.info(
`Sending deleteForEveryone to conversation ${logId}`,
`with timestamp ${timestamp}`,
`for message ${targetTimestamp}`
`${logId}: Sending deleteForEveryone with timestamp ${timestamp}` +
`for message ${targetTimestamp}, isStory=${story}`
);
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';
import type { LoggerType } from '../../types/Logging';
import type { MessageModel } from '../../models/messages';
import type { SenderKeyInfoType } from '../../model-types.d';
import type {
SendState,
SendStateByConversationId,
@ -25,6 +24,7 @@ import {
} from '../../messages/MessageSendState';
import type { UUIDStringType } from '../../types/UUID';
import * as Errors from '../../types/errors';
import type { StoryMessageRecipientsType } from '../../types/Stories';
import dataInterface from '../../sql/Client';
import { SignalService as Proto } from '../../protobuf';
import { getMessagesById } from '../../messages/getMessagesById';
@ -35,9 +35,9 @@ import {
import { handleMessageSend } from '../../util/handleMessageSend';
import { handleMultipleSendErrors } from './handleMultipleSendErrors';
import { isGroupV2, isMe } from '../../util/whatTypeOfConversation';
import { isNotNil } from '../../util/isNotNil';
import { ourProfileKeyService } from '../../services/ourProfileKey';
import { sendContentMessageToGroup } from '../../util/sendToGroup';
import { distributionListToSendTarget } from '../../util/distributionListToSendTarget';
import { SendMessageChallengeError } from '../../textsecure/Errors';
export async function sendStory(
@ -283,8 +283,6 @@ export async function sendStory(
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
const recipientsSet = new Set(pendingSendRecipientIds);
const sendOptions = await getSendOptionsForRecipients(
pendingSendRecipientIds,
{ story: true }
@ -303,28 +301,11 @@ export async function sendStory(
isGroupV2(conversation.attributes) ||
Boolean(distributionList?.allowsReplies);
let inMemorySenderKeyInfo = distributionList?.senderKeyInfo;
const sendTarget = distributionList
? {
getGroupId: () => undefined,
getMembers: () =>
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,
});
},
}
? distributionListToSendTarget(
distributionList,
pendingSendRecipientIds
)
: conversation.toSenderKeyTarget();
const contentMessage = new Proto.Content();
@ -530,11 +511,7 @@ export async function sendStory(
});
// Build up the sync message's storyMessageRecipients and send it
const storyMessageRecipients: Array<{
destinationUuid: string;
distributionListIds: Array<string>;
isAllowedToReply: boolean;
}> = [];
const storyMessageRecipients: StoryMessageRecipientsType = [];
recipientsByUuid.forEach((distributionListIds, destinationUuid) => {
storyMessageRecipients.push({
destinationUuid,

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

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

View file

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

View file

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

View file

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

View file

@ -166,3 +166,9 @@ export enum ResolvedSendStatus {
Sending = 'Sending',
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 isValidUuid = (value: unknown): value is UUIDStringType =>
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.test(
value
);
const UUID_REGEXP =
/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i;
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 {
constructor(protected readonly value: string) {

View file

@ -2,12 +2,25 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { noop } from 'lodash';
import type { ConversationQueueJobData } from '../jobs/conversationJobQueue';
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 { StoryRecipientUpdateEvent } from '../textsecure/messageReceiverEvents';
import { getSendOptions } from './getSendOptions';
import {
conversationJobQueue,
conversationQueueJobEnum,
} from '../jobs/conversationJobQueue';
import { onStoryRecipientUpdate } from './onStoryRecipientUpdate';
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(
stories: ReadonlyArray<StoryDataType>,
@ -17,8 +30,31 @@ export async function deleteStoryForEveryone(
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 updatedStoryRecipients = new Map<
const newStoryRecipients = new Map<
string,
{
distributionListIds: Set<string>;
@ -32,6 +68,30 @@ export async function deleteStoryForEveryone(
// Remove ourselves from the DOE.
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
// send a DOE request to the members of those lists.
stories.forEach(item => {
@ -62,120 +122,95 @@ export async function deleteStoryForEveryone(
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
// still have access.
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
conversationIds.forEach(cid => {
// Don't DOE yourself!
if (cid === ourConversation.id) {
return;
}
log.info(`${logId}: enqueing DeleteStoryForEveryone`);
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) {
return;
}
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,
await window.Signal.Data.saveMessage(message.attributes, {
jobToInsert,
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
});
});
const destinationUuid = ourConversation.get('uuid');
if (!destinationUuid) {
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
} catch (error) {
log.error(
`${logId}: Failed to queue delete for everyone`,
Errors.toLogFormat(error)
);
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 { SendStatus } from '../messages/MessageSendState';
import { deleteForEveryone } from './deleteForEveryone';
import {
getConversationIdForLogging,
getMessageIdForLogging,
} from './idForLogging';
import { getConversationIdForLogging } from './idForLogging';
import { isStory } from '../state/selectors/message';
import { normalizeUuid } from './normalizeUuid';
import { queueUpdateMessage } from './messageBatcher';
@ -25,8 +22,10 @@ export async function onStoryRecipientUpdate(
const conversation = window.ConversationController.get(destinationUuid);
const logId = `onStoryRecipientUpdate(${destinationUuid}, ${timestamp})`;
if (!conversation) {
log.info(`onStoryRecipientUpdate no conversation for ${destinationUuid}`);
log.info(`${logId}: no conversation`);
return;
}
@ -37,20 +36,16 @@ export async function onStoryRecipientUpdate(
);
if (!targetConversation) {
log.info('onStoryRecipientUpdate !targetConversation', {
destinationUuid,
timestamp,
});
log.info(`${logId}: no targetConversation`);
return;
}
targetConversation.queueJob('onStoryRecipientUpdate', async () => {
log.info('onStoryRecipientUpdate updating', timestamp);
targetConversation.queueJob(logId, async () => {
log.info(`${logId}: updating`);
// Build up some maps for fast/easy lookups
const isAllowedToReply = new Map<string, boolean>();
const conversationIdToDistributionListIds = new Map<string, Set<string>>();
const distributionListIdToConversationIds = new Map<string, Set<string>>();
data.storyMessageRecipients.forEach(item => {
const convo = window.ConversationController.get(item.destinationUuid);
@ -58,14 +53,16 @@ export async function onStoryRecipientUpdate(
return;
}
conversationIdToDistributionListIds.set(
convo.id,
new Set(
item.distributionListIds.map(uuid =>
normalizeUuid(uuid, 'onStoryRecipientUpdate.distributionListId')
)
)
);
for (const rawUuid of item.distributionListIds) {
const uuid = normalizeUuid(rawUuid, `${logId}.distributionListId`);
const existing = distributionListIdToConversationIds.get(uuid);
if (existing === undefined) {
distributionListIdToConversationIds.set(uuid, new Set([convo.id]));
} else {
existing.add(convo.id);
}
}
isAllowedToReply.set(convo.id, item.isAllowedToReply !== false);
});
@ -87,55 +84,60 @@ export async function onStoryRecipientUpdate(
return false;
}
const newConversationIds =
distributionListIdToConversationIds.get(storyDistributionListId) ??
new Set();
const nextSendStateByConversationId = {
...sendStateByConversationId,
};
conversationIdToDistributionListIds.forEach(
(distributionListIds, conversationId) => {
const hasDistributionListId = distributionListIds.has(
storyDistributionListId
);
// Find conversation ids present in the local send state, but missing
// in the remote state, and remove them from the local state.
for (const oldId of Object.keys(sendStateByConversationId)) {
if (!newConversationIds.has(oldId)) {
const recipient = window.ConversationController.get(oldId);
const recipient = window.ConversationController.get(conversationId);
const conversationIdForLogging = recipient
const recipientLogId = recipient
? getConversationIdForLogging(recipient.attributes)
: conversationId;
: oldId;
if (
hasDistributionListId &&
!sendStateByConversationId[conversationId]
) {
log.info('onStoryRecipientUpdate adding', {
conversationId: conversationIdForLogging,
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];
}
log.info(`${logId}: removing`, {
recipient: recipientLogId,
messageId: item.id,
storyDistributionListId,
});
delete nextSendStateByConversationId[oldId];
}
);
}
// 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)) {
log.info(
'onStoryRecipientUpdate: sendStateByConversationId does not need update'
);
log.info(`${logId}: sendStateByConversationId does not need update`, {
messageId: item.id,
});
return true;
}
@ -150,8 +152,8 @@ export async function onStoryRecipientUpdate(
(sendStateConversationIds.size === 1 &&
sendStateConversationIds.has(ourConversationId))
) {
log.info('onStoryRecipientUpdate DOE', {
messageId: getMessageIdForLogging(item),
log.info(`${logId} DOE`, {
messageId: item.id,
storyDistributionListId,
});
const delAttributes: DeleteAttributesType = {

View file

@ -40,8 +40,7 @@ export async function sendDeleteForEveryoneMessage(
if (!message) {
throw new Error('sendDeleteForEveryoneMessage: Cannot find message!');
}
const messageModel = window.MessageController.register(messageId, message);
const idForLogging = getMessageIdForLogging(messageModel.attributes);
const idForLogging = getMessageIdForLogging(message.attributes);
const timestamp = Date.now();
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}`);
}
messageModel.set({
message.set({
deletedForEveryoneSendStatus: zipObject(
getRecipientConversationIds(conversationAttributes),
repeat(false)
@ -79,7 +78,7 @@ export async function sendDeleteForEveryoneMessage(
`sendDeleteForEveryoneMessage: Deleting message ${idForLogging} ` +
`in conversation ${conversationIdForLogging} with job ${jobToInsert.id}`
);
await window.Signal.Data.saveMessage(messageModel.attributes, {
await window.Signal.Data.saveMessage(message.attributes, {
jobToInsert,
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
});
@ -97,5 +96,5 @@ export async function sendDeleteForEveryoneMessage(
serverTimestamp: Date.now(),
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({
conversation,
logId,
logId: parentLogId,
messageIds,
send,
sendType,
@ -28,11 +28,10 @@ export async function wrapWithSyncMessageSend({
sendType: SendTypesType;
timestamp: number;
}): Promise<void> {
const logId = `wrapWithSyncMessageSend(${parentLogId}, ${timestamp})`;
const sender = window.textsecure.messaging;
if (!sender) {
throw new Error(
`wrapWithSyncMessageSend/${logId}: textsecure.messaging is not available!`
);
throw new Error(`${logId}: textsecure.messaging is not available!`);
}
let response: CallbackResultType | undefined;
@ -52,17 +51,13 @@ export async function wrapWithSyncMessageSend({
if (thrown instanceof Error) {
error = thrown;
} else {
log.error(
`wrapWithSyncMessageSend/${logId}: Thrown value was not an Error, returning early`
);
log.error(`${logId}: Thrown value was not an Error, returning early`);
throw error;
}
}
if (!response && !error) {
throw new Error(
`wrapWithSyncMessageSend/${logId}: message send didn't return result or error!`
);
throw new Error(`${logId}: message send didn't return result or error!`);
}
const dataMessage =
@ -71,11 +66,9 @@ export async function wrapWithSyncMessageSend({
if (didSuccessfullySendOne) {
if (!dataMessage) {
log.error(
`wrapWithSyncMessageSend/${logId}: dataMessage was not returned by send!`
);
log.error(`${logId}: dataMessage was not returned by send!`);
} else {
log.info(`wrapWithSyncMessageSend/${logId}: Sending sync message...`);
log.info(`${logId}: Sending sync message... `);
const ourConversation =
window.ConversationController.getOurConversationOrThrow();
const options = await getSendOptions(ourConversation.attributes, {
@ -99,7 +92,8 @@ export async function wrapWithSyncMessageSend({
if (error instanceof Error) {
if (areAllErrorsUnregistered(conversation.attributes, error)) {
log.info(
`wrapWithSyncMessageSend/${logId}: Group send failures were all UnregisteredUserError, returning succcessfully.`
`${logId}: Group send failures were all UnregisteredUserError, ` +
'returning succcessfully.'
);
return;
}