// Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { noop, union } from 'lodash'; import { filter, map } from '../util/iterables'; import { isNotNil } from '../util/isNotNil'; import { SendMessageProtoError } from '../textsecure/Errors'; import { getOwn } from '../util/getOwn'; import { isGroup } from '../util/whatTypeOfConversation'; import { handleMessageSend } from '../util/handleMessageSend'; import { getSendOptions } from '../util/getSendOptions'; import * as log from '../logging/log'; import { DataWriter } from '../sql/Client'; import { getPropForTimestamp, getChangesForPropAtTimestamp, } from '../util/editHelpers'; import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp'; import { notifyStorySendFailed, saveErrorsOnMessage, } from '../test-node/util/messageFailures'; import { postSaveUpdates } from '../util/cleanup'; import { isCustomError } from './helpers'; import { SendActionType, isSent, sendStateReducer } from './MessageSendState'; import type { CustomError, MessageAttributesType } from '../model-types.d'; import type { CallbackResultType } from '../textsecure/Types.d'; import type { MessageModel } from '../models/messages'; import type { ServiceIdString } from '../types/ServiceId'; import type { SendStateByConversationId } from './MessageSendState'; /* eslint-disable more/no-then */ export async function send( message: MessageModel, { promise, saveErrors, targetTimestamp, }: { promise: Promise; saveErrors?: (errors: Array) => void; targetTimestamp: number; } ): Promise { const conversation = window.ConversationController.get( message.attributes.conversationId ); const updateLeftPane = conversation?.debouncedUpdateLastMessage ?? noop; updateLeftPane(); let result: | { success: true; value: CallbackResultType } | { success: false; value: CustomError | SendMessageProtoError; }; try { const value = await (promise as Promise); result = { success: true, value }; } catch (err) { result = { success: false, value: err }; } updateLeftPane(); const attributesToUpdate: Partial = {}; // This is used by sendSyncMessage, then set to null if ('dataMessage' in result.value && result.value.dataMessage) { attributesToUpdate.dataMessage = result.value.dataMessage; } else if ('editMessage' in result.value && result.value.editMessage) { attributesToUpdate.dataMessage = result.value.editMessage; } if (!message.doNotSave) { await DataWriter.saveMessage(message.attributes, { ourAci: window.textsecure.storage.user.getCheckedAci(), postSaveUpdates, }); } const sendStateByConversationId = { ...(getPropForTimestamp({ log, message: message.attributes, prop: 'sendStateByConversationId', targetTimestamp, }) || {}), }; const sendIsNotFinal = 'sendIsNotFinal' in result.value && result.value.sendIsNotFinal; const sendIsFinal = !sendIsNotFinal; // Capture successful sends const successfulServiceIds: Array = sendIsFinal && 'successfulServiceIds' in result.value && Array.isArray(result.value.successfulServiceIds) ? result.value.successfulServiceIds : []; const sentToAtLeastOneRecipient = result.success || Boolean(successfulServiceIds.length); successfulServiceIds.forEach(serviceId => { const targetConversation = window.ConversationController.get(serviceId); if (!targetConversation) { return; } // If we successfully sent to a user, we can remove our unregistered flag. if (targetConversation.isEverUnregistered()) { targetConversation.setRegistered(); } const previousSendState = getOwn( sendStateByConversationId, targetConversation.id ); if (previousSendState) { sendStateByConversationId[targetConversation.id] = sendStateReducer( previousSendState, { type: SendActionType.Sent, updatedAt: Date.now(), } ); } }); // Integrate sends via sealed sender const latestEditTimestamp = message.get('editMessageTimestamp'); const sendIsLatest = !latestEditTimestamp || targetTimestamp === latestEditTimestamp; const previousUnidentifiedDeliveries = message.get('unidentifiedDeliveries') || []; const newUnidentifiedDeliveries = sendIsLatest && sendIsFinal && 'unidentifiedDeliveries' in result.value && Array.isArray(result.value.unidentifiedDeliveries) ? result.value.unidentifiedDeliveries : []; const promises: Array> = []; // Process errors let errors: Array; if (result.value instanceof SendMessageProtoError && result.value.errors) { ({ errors } = result.value); } else if (isCustomError(result.value)) { errors = [result.value]; } else if (Array.isArray(result.value.errors)) { ({ errors } = result.value); } else { errors = []; } // In groups, we don't treat unregistered users as a user-visible // error. The message will look successful, but the details // screen will show that we didn't send to these unregistered users. const errorsToSave: Array = []; errors.forEach(error => { const errorConversation = window.ConversationController.get(error.serviceId) || window.ConversationController.get(error.number); if (errorConversation && !saveErrors && sendIsFinal) { const previousSendState = getOwn( sendStateByConversationId, errorConversation.id ); if (previousSendState) { sendStateByConversationId[errorConversation.id] = sendStateReducer( previousSendState, { type: SendActionType.Failed, updatedAt: Date.now(), } ); notifyStorySendFailed(message); } } let shouldSaveError = true; switch (error.name) { case 'OutgoingIdentityKeyError': { if (conversation) { promises.push( conversation.getProfiles().catch(() => { /* nothing to do here; logging already happened */ }) ); } break; } case 'UnregisteredUserError': if (conversation && isGroup(conversation.attributes)) { shouldSaveError = false; } // If we just found out that we couldn't send to a user because they are no // longer registered, we will update our unregistered flag. In groups we // will not event try to send to them for 6 hours. And we will never try // to fetch them on startup again. // // The way to discover registration once more is: // 1) any attempt to send to them in 1:1 conversation // 2) the six-hour time period has passed and we send in a group again conversation?.setUnregistered(); break; default: break; } if (shouldSaveError) { errorsToSave.push(error); } }); // Only update the expirationStartTimestamp if we don't already have one set if (!message.get('expirationStartTimestamp')) { attributesToUpdate.expirationStartTimestamp = sentToAtLeastOneRecipient ? Date.now() : undefined; } attributesToUpdate.unidentifiedDeliveries = union( previousUnidentifiedDeliveries, newUnidentifiedDeliveries ); // We may overwrite this in the `saveErrors` call below. attributesToUpdate.errors = []; const additionalProps = getChangesForPropAtTimestamp({ log, message: message.attributes, prop: 'sendStateByConversationId', targetTimestamp, value: sendStateByConversationId, }); message.set({ ...attributesToUpdate, ...additionalProps }); if (saveErrors) { saveErrors(errorsToSave); } else { // We skip save because we'll save in the next step. await saveErrorsOnMessage(message, errorsToSave, { skipSave: true, }); } if (!message.doNotSave) { await window.MessageCache.saveMessage(message); } updateLeftPane(); if (sentToAtLeastOneRecipient && !message.doNotSendSyncMessage) { promises.push(sendSyncMessage(message, targetTimestamp)); } await Promise.all(promises); updateLeftPane(); } export async function sendSyncMessageOnly( message: MessageModel, { targetTimestamp, dataMessage, saveErrors, }: { targetTimestamp: number; dataMessage: Uint8Array; saveErrors?: (errors: Array) => void; } ): Promise { const conv = window.ConversationController.get( message.attributes.conversationId ); message.set({ dataMessage }); const updateLeftPane = conv?.debouncedUpdateLastMessage; try { message.set({ // This is the same as a normal send() expirationStartTimestamp: Date.now(), errors: [], }); const result = await sendSyncMessage(message, targetTimestamp); message.set({ // We have to do this afterward, since we didn't have a previous send! unidentifiedDeliveries: result && result.unidentifiedDeliveries ? result.unidentifiedDeliveries : undefined, }); return result; } catch (error) { const resultErrors = error?.errors; const errors = Array.isArray(resultErrors) ? resultErrors : [new Error('Unknown error')]; if (saveErrors) { saveErrors(errors); } else { // We don't save because we're about to save below. await saveErrorsOnMessage(message, errors, { skipSave: true, }); } throw error; } finally { await DataWriter.saveMessage(message.attributes, { ourAci: window.textsecure.storage.user.getCheckedAci(), postSaveUpdates, }); if (updateLeftPane) { updateLeftPane(); } } } export async function sendSyncMessage( message: MessageModel, targetTimestamp: number ): Promise { const ourConversation = window.ConversationController.getOurConversationOrThrow(); const sendOptions = await getSendOptions(ourConversation.attributes, { syncMessage: true, }); if (window.ConversationController.areWePrimaryDevice()) { log.warn( 'sendSyncMessage: We are primary device; not sending sync message' ); message.set({ dataMessage: undefined }); return; } const { messaging } = window.textsecure; if (!messaging) { throw new Error('sendSyncMessage: messaging not available!'); } // eslint-disable-next-line no-param-reassign message.syncPromise = message.syncPromise || Promise.resolve(); const next = async () => { const dataMessage = message.get('dataMessage'); if (!dataMessage) { return; } const originalTimestamp = getMessageSentTimestamp(message.attributes, { includeEdits: false, log, }); const isSendingEdit = targetTimestamp !== originalTimestamp; const isUpdate = Boolean(message.get('synced')) && !isSendingEdit; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const conv = window.ConversationController.get( message.attributes.conversationId )!; const sendEntries = Object.entries( getPropForTimestamp({ log, message: message.attributes, prop: 'sendStateByConversationId', targetTimestamp, }) || {} ); const sentEntries = filter(sendEntries, ([_conversationId, { status }]) => isSent(status) ); const allConversationIdsSentTo = map( sentEntries, ([conversationId]) => conversationId ); const conversationIdsSentTo = filter( allConversationIdsSentTo, conversationId => conversationId !== ourConversation.id ); const unidentifiedDeliveries = message.get('unidentifiedDeliveries') || []; const maybeConversationsWithSealedSender = map( unidentifiedDeliveries, identifier => window.ConversationController.get(identifier) ); const conversationsWithSealedSender = filter( maybeConversationsWithSealedSender, isNotNil ); const conversationIdsWithSealedSender = new Set( map(conversationsWithSealedSender, c => c.id) ); const encodedContent = isSendingEdit ? { encodedEditMessage: dataMessage, } : { encodedDataMessage: dataMessage, }; return handleMessageSend( messaging.sendSyncMessage({ ...encodedContent, timestamp: targetTimestamp, destination: conv.get('e164'), destinationServiceId: conv.getServiceId(), expirationStartTimestamp: message.get('expirationStartTimestamp') || null, conversationIdsSentTo, conversationIdsWithSealedSender, isUpdate, options: sendOptions, urgent: false, }), // Note: in some situations, for doNotSave messages, the message has no // id, so we provide an empty array here. { messageIds: message.id ? [message.id] : [], sendType: 'sentSync' } ).then(async result => { let newSendStateByConversationId: undefined | SendStateByConversationId; const sendStateByConversationId = getPropForTimestamp({ log, message: message.attributes, prop: 'sendStateByConversationId', targetTimestamp, }) || {}; const ourOldSendState = getOwn( sendStateByConversationId, ourConversation.id ); if (ourOldSendState) { const ourNewSendState = sendStateReducer(ourOldSendState, { type: SendActionType.Sent, updatedAt: Date.now(), }); if (ourNewSendState !== ourOldSendState) { newSendStateByConversationId = { ...sendStateByConversationId, [ourConversation.id]: ourNewSendState, }; } } const attributesForUpdate = newSendStateByConversationId ? getChangesForPropAtTimestamp({ log, message: message.attributes, prop: 'sendStateByConversationId', value: newSendStateByConversationId, targetTimestamp, }) : null; message.set({ synced: true, dataMessage: null, ...attributesForUpdate, }); // Return early, skip the save if (message.doNotSave) { return result; } await DataWriter.saveMessage(message.attributes, { ourAci: window.textsecure.storage.user.getCheckedAci(), postSaveUpdates, }); return result; }); }; // eslint-disable-next-line no-param-reassign message.syncPromise = message.syncPromise.then(next, next); return message.syncPromise; }