signal-desktop/ts/messages/send.ts
2025-01-10 08:18:32 +10:00

491 lines
14 KiB
TypeScript

// 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<CallbackResultType | void | null>;
saveErrors?: (errors: Array<Error>) => void;
targetTimestamp: number;
}
): Promise<void> {
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<CallbackResultType>);
result = { success: true, value };
} catch (err) {
result = { success: false, value: err };
}
updateLeftPane();
const attributesToUpdate: Partial<MessageAttributesType> = {};
// 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<ServiceIdString> =
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<Promise<unknown>> = [];
// Process errors
let errors: Array<CustomError>;
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<CustomError> = [];
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<Error>) => void;
}
): Promise<CallbackResultType | void> {
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<CallbackResultType | void> {
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;
}