2022-08-02 19:31:55 +00:00
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isEqual } from 'lodash' ;
2022-08-04 19:23:24 +00:00
import type {
AttachmentWithHydratedData ,
TextAttachmentType ,
} from '../../types/Attachment' ;
2022-08-02 19:31:55 +00:00
import type { ConversationModel } from '../../models/conversations' ;
import type {
ConversationQueueJobBundle ,
StoryJobData ,
} 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 ,
} from '../../messages/MessageSendState' ;
import type { UUIDStringType } from '../../types/UUID' ;
import * as Errors from '../../types/errors' ;
import dataInterface from '../../sql/Client' ;
import { SignalService as Proto } from '../../protobuf' ;
import { getMessageById } from '../../messages/getMessageById' ;
import {
getSendOptions ,
getSendOptionsForRecipients ,
} from '../../util/getSendOptions' ;
import { handleMessageSend } from '../../util/handleMessageSend' ;
import { handleMultipleSendErrors } from './handleMultipleSendErrors' ;
2022-08-09 03:26:21 +00:00
import { isGroupV2 , isMe } from '../../util/whatTypeOfConversation' ;
2022-08-02 19:31:55 +00:00
import { isNotNil } from '../../util/isNotNil' ;
import { isSent } from '../../messages/MessageSendState' ;
import { ourProfileKeyService } from '../../services/ourProfileKey' ;
import { sendContentMessageToGroup } from '../../util/sendToGroup' ;
export async function sendStory (
conversation : ConversationModel ,
{
isFinalAttempt ,
messaging ,
shouldContinue ,
timeRemaining ,
log ,
} : ConversationQueueJobBundle ,
data : StoryJobData
) : Promise < void > {
2022-08-04 19:23:24 +00:00
const { messageIds , timestamp } = data ;
2022-08-02 19:31:55 +00:00
const profileKey = await ourProfileKeyService . get ( ) ;
if ( ! profileKey ) {
log . info ( 'stories.sendStory: no profile key cannot send' ) ;
return ;
}
2022-08-04 19:23:24 +00:00
// We want to generate the StoryMessage proto once at the top level so we
// can reuse it but first we'll need textAttachment | fileAttachment.
// This function pulls off the attachment and generates the proto from the
// first message on the list prior to continuing.
const originalStoryMessage = await ( async ( ) : Promise <
Proto . StoryMessage | undefined
> = > {
const [ messageId ] = messageIds ;
const message = await getMessageById ( messageId ) ;
if ( ! message ) {
log . info (
2022-08-09 03:26:21 +00:00
` stories.sendStory( ${ messageId } ): message was not found, maybe because it was deleted. Giving up on sending it `
2022-08-04 19:23:24 +00:00
) ;
return ;
}
2022-08-10 18:37:19 +00:00
const messageConversation = message . getConversation ( ) ;
if ( messageConversation !== conversation ) {
log . error (
` stories.sendStory( ${ messageId } ): Message conversation ' ${ messageConversation ? . idForLogging ( ) } ' does not match job conversation ${ conversation . idForLogging ( ) } `
) ;
return ;
}
2022-08-04 19:23:24 +00:00
const attachments = message . get ( 'attachments' ) || [ ] ;
const [ attachment ] = attachments ;
if ( ! attachment ) {
log . info (
2022-08-09 03:26:21 +00:00
` stories.sendStory( ${ messageId } ): message does not have any attachments to send. Giving up on sending it `
2022-08-04 19:23:24 +00:00
) ;
return ;
}
let textAttachment : TextAttachmentType | undefined ;
let fileAttachment : AttachmentWithHydratedData | undefined ;
if ( attachment . textAttachment ) {
textAttachment = attachment . textAttachment ;
} else {
fileAttachment = await window . Signal . Migrations . loadAttachmentData (
attachment
) ;
}
2022-08-10 18:37:19 +00:00
const groupV2 = isGroupV2 ( conversation . attributes )
? conversation . getGroupV2Info ( )
: undefined ;
2022-08-04 19:23:24 +00:00
// Some distribution lists need allowsReplies false, some need it set to true
// we create this proto (for the sync message) and also to re-use some of the
// attributes inside it.
return messaging . getStoryMessage ( {
allowsReplies : true ,
fileAttachment ,
2022-08-10 18:37:19 +00:00
groupV2 ,
2022-08-04 19:23:24 +00:00
textAttachment ,
profileKey ,
} ) ;
} ) ( ) ;
if ( ! originalStoryMessage ) {
return ;
}
2022-08-02 19:31:55 +00:00
const canReplyUuids = new Set < string > ( ) ;
const recipientsByUuid = new Map < string , Set < string > > ( ) ;
2022-08-09 03:26:21 +00:00
const sentConversationIds = new Map < string , SendState > ( ) ;
const sentUuids = new Set < string > ( ) ;
2022-08-02 19:31:55 +00:00
// This function is used to keep track of all the recipients so once we're
// done with our send we can build up the storyMessageRecipients object for
// sending in the sync message.
2022-08-09 03:26:21 +00:00
function addDistributionListToUuidSent (
listId : string | undefined ,
2022-08-02 19:31:55 +00:00
uuid : string ,
canReply? : boolean
) : void {
if ( conversation . get ( 'uuid' ) === uuid ) {
return ;
}
const distributionListIds = recipientsByUuid . get ( uuid ) || new Set < string > ( ) ;
2022-08-09 03:26:21 +00:00
if ( listId ) {
recipientsByUuid . set ( uuid , new Set ( [ . . . distributionListIds , listId ] ) ) ;
} else {
recipientsByUuid . set ( uuid , distributionListIds ) ;
}
2022-08-02 19:31:55 +00:00
if ( canReply ) {
canReplyUuids . add ( uuid ) ;
}
}
let isSyncMessageUpdate = false ;
// Send to all distribution lists
await Promise . all (
messageIds . map ( async messageId = > {
const message = await getMessageById ( messageId ) ;
if ( ! message ) {
log . info (
2022-08-09 03:26:21 +00:00
` stories.sendStory( ${ messageId } ): message was not found, maybe because it was deleted. Giving up on sending it `
2022-08-02 19:31:55 +00:00
) ;
return ;
}
const messageConversation = message . getConversation ( ) ;
if ( messageConversation !== conversation ) {
log . error (
2022-08-10 18:37:19 +00:00
` stories.sendStory( ${ messageId } ): Message conversation ' ${ messageConversation ? . idForLogging ( ) } ' does not match job conversation ${ conversation . idForLogging ( ) } `
2022-08-02 19:31:55 +00:00
) ;
return ;
}
if ( message . isErased ( ) || message . get ( 'deletedForEveryone' ) ) {
log . info (
2022-08-09 03:26:21 +00:00
` stories.sendStory( ${ messageId } ): message was erased. Giving up on sending it `
2022-08-02 19:31:55 +00:00
) ;
return ;
}
const listId = message . get ( 'storyDistributionListId' ) ;
2022-08-09 03:26:21 +00:00
const receiverId = isGroupV2 ( messageConversation . attributes )
? messageConversation . id
: listId ;
2022-08-02 19:31:55 +00:00
2022-08-09 03:26:21 +00:00
if ( ! receiverId ) {
2022-08-02 19:31:55 +00:00
log . info (
2022-08-09 03:26:21 +00:00
` stories.sendStory( ${ messageId } ): did not get a valid recipient ID for message. Giving up on sending it `
2022-08-02 19:31:55 +00:00
) ;
return ;
}
2022-08-09 03:26:21 +00:00
const distributionList = isGroupV2 ( messageConversation . attributes )
? undefined
: await dataInterface . getStoryDistributionWithMembers ( receiverId ) ;
2022-08-02 19:31:55 +00:00
let messageSendErrors : Array < Error > = [ ] ;
// We don't want to save errors on messages unless we're giving up. If it's our
// final attempt, we know upfront that we want to give up. However, we might also
// want to give up if (1) we get a 508 from the server, asking us to please stop
// (2) we get a 428 from the server, flagging the message for spam (3) some other
// reason not known at the time of this writing.
//
// This awkward callback lets us hold onto errors we might want to save, so we can
// decide whether to save them later on.
const saveErrors = isFinalAttempt
? undefined
: ( errors : Array < Error > ) = > {
messageSendErrors = errors ;
} ;
if ( ! shouldContinue ) {
log . info (
2022-08-09 03:26:21 +00:00
` stories.sendStory( ${ messageId } ): ran out of time. Giving up on sending it `
2022-08-02 19:31:55 +00:00
) ;
await markMessageFailed ( message , [
new Error ( 'Message send ran out of time' ) ,
] ) ;
return ;
}
let originalError : Error | undefined ;
const {
2022-08-09 03:26:21 +00:00
allRecipientIds ,
2022-08-02 19:31:55 +00:00
allowedReplyByUuid ,
2022-08-09 03:26:21 +00:00
pendingSendRecipientIds ,
sentRecipientIds ,
2022-08-02 19:31:55 +00:00
untrustedUuids ,
} = getMessageRecipients ( {
log ,
message ,
} ) ;
try {
if ( untrustedUuids . length ) {
window . reduxActions . conversations . conversationStoppedByMissingVerification (
{
conversationId : conversation.id ,
untrustedUuids ,
}
) ;
throw new Error (
2022-08-09 03:26:21 +00:00
` stories.sendStory( ${ messageId } ): sending blocked because ${ untrustedUuids . length } conversation(s) were untrusted. Failing this attempt. `
2022-08-02 19:31:55 +00:00
) ;
}
2022-08-09 03:26:21 +00:00
if ( ! pendingSendRecipientIds . length ) {
allRecipientIds . forEach ( uuid = >
addDistributionListToUuidSent (
2022-08-02 19:31:55 +00:00
listId ,
uuid ,
allowedReplyByUuid . get ( uuid )
)
) ;
return ;
}
const { ContentHint } = Proto . UnidentifiedSenderMessage . Message ;
2022-08-09 03:26:21 +00:00
const recipientsSet = new Set ( pendingSendRecipientIds ) ;
2022-08-02 19:31:55 +00:00
const sendOptions = await getSendOptionsForRecipients (
2022-08-09 03:26:21 +00:00
pendingSendRecipientIds
2022-08-02 19:31:55 +00:00
) ;
log . info (
2022-08-09 03:26:21 +00:00
` stories.sendStory( ${ messageId } ): sending story to ${ receiverId } `
2022-08-02 19:31:55 +00:00
) ;
const storyMessage = new Proto . StoryMessage ( ) ;
storyMessage . profileKey = originalStoryMessage . profileKey ;
storyMessage . fileAttachment = originalStoryMessage . fileAttachment ;
storyMessage . textAttachment = originalStoryMessage . textAttachment ;
storyMessage . group = originalStoryMessage . group ;
2022-08-09 03:26:21 +00:00
storyMessage . allowsReplies =
isGroupV2 ( messageConversation . attributes ) ||
Boolean ( distributionList ? . allowsReplies ) ;
2022-09-08 23:17:38 +00:00
let inMemorySenderKeyInfo = distributionList ? . senderKeyInfo ;
2022-08-09 03:26:21 +00:00
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 ,
2022-09-08 23:17:38 +00:00
getSenderKeyInfo : ( ) = > inMemorySenderKeyInfo ,
saveSenderKeyInfo : async ( senderKeyInfo : SenderKeyInfoType ) = > {
inMemorySenderKeyInfo = senderKeyInfo ;
await dataInterface . modifyStoryDistribution ( {
2022-08-09 03:26:21 +00:00
. . . distributionList ,
senderKeyInfo ,
2022-09-08 23:17:38 +00:00
} ) ;
} ,
2022-08-09 03:26:21 +00:00
}
: conversation . toSenderKeyTarget ( ) ;
2022-08-02 19:31:55 +00:00
const contentMessage = new Proto . Content ( ) ;
contentMessage . storyMessage = storyMessage ;
const innerPromise = sendContentMessageToGroup ( {
contentHint : ContentHint.IMPLICIT ,
contentMessage ,
isPartialSend : false ,
messageId : undefined ,
2022-08-09 03:26:21 +00:00
recipients : pendingSendRecipientIds ,
2022-08-02 19:31:55 +00:00
sendOptions ,
2022-08-09 03:26:21 +00:00
sendTarget ,
2022-08-02 19:31:55 +00:00
sendType : 'story' ,
2022-08-19 21:12:05 +00:00
timestamp : message.get ( 'timestamp' ) ,
2022-08-02 19:31:55 +00:00
urgent : false ,
} ) ;
2022-08-10 18:37:19 +00:00
// Do not send sync messages for distribution lists since that's sent
// in bulk at the end.
message . doNotSendSyncMessage = Boolean ( distributionList ) ;
2022-08-02 19:31:55 +00:00
const messageSendPromise = message . send (
handleMessageSend ( innerPromise , {
messageIds : [ messageId ] ,
sendType : 'story' ,
} ) ,
saveErrors
) ;
// Because message.send swallows and processes errors, we'll await the
// inner promise to get the SendMessageProtoError, which gives us
// information upstream processors need to detect certain kinds of situations.
try {
await innerPromise ;
} catch ( error ) {
if ( error instanceof Error ) {
originalError = error ;
} else {
log . error (
` promiseForError threw something other than an error: ${ Errors . toLogFormat (
error
) } `
) ;
}
}
await messageSendPromise ;
// Track sendState across message sends so that we can update all
// subsequent messages.
const sendStateByConversationId =
message . get ( 'sendStateByConversationId' ) || { } ;
Object . entries ( sendStateByConversationId ) . forEach (
( [ recipientConversationId , sendState ] ) = > {
2022-08-09 03:26:21 +00:00
if ( ! isSent ( sendState . status ) ) {
2022-08-02 19:31:55 +00:00
return ;
}
2022-08-09 03:26:21 +00:00
sentConversationIds . set ( recipientConversationId , sendState ) ;
const recipient = window . ConversationController . get (
recipientConversationId
2022-08-02 19:31:55 +00:00
) ;
2022-08-09 03:26:21 +00:00
const uuid = recipient ? . get ( 'uuid' ) ;
if ( ! uuid ) {
return ;
}
sentUuids . add ( uuid ) ;
2022-08-02 19:31:55 +00:00
}
) ;
2022-08-09 03:26:21 +00:00
allRecipientIds . forEach ( uuid = > {
addDistributionListToUuidSent (
listId ,
uuid ,
allowedReplyByUuid . get ( uuid )
) ;
} ) ;
2022-08-02 19:31:55 +00:00
const didFullySend =
! messageSendErrors . length || didSendToEveryone ( message ) ;
if ( ! didFullySend ) {
throw new Error ( 'message did not fully send' ) ;
}
} catch ( thrownError : unknown ) {
const errors = [ thrownError , . . . messageSendErrors ] ;
await handleMultipleSendErrors ( {
errors ,
isFinalAttempt ,
log ,
markFailed : ( ) = > markMessageFailed ( message , messageSendErrors ) ,
timeRemaining ,
// In the case of a failed group send thrownError will not be
// SentMessageProtoError, but we should have been able to harvest
// the original error. In the Note to Self send case, thrownError
// will be the error we care about, and we won't have an originalError.
toThrow : originalError || thrownError ,
} ) ;
} finally {
2022-08-09 03:26:21 +00:00
isSyncMessageUpdate = sentRecipientIds . length > 0 ;
}
} )
) ;
// Some contacts are duplicated across lists and we don't send duplicate
// messages but we still want to make sure that the sendStateByConversationId
// is kept in sync across all messages.
await Promise . all (
messageIds . map ( async messageId = > {
const message = await getMessageById ( messageId ) ;
if ( ! message ) {
return ;
2022-08-02 19:31:55 +00:00
}
2022-08-09 03:26:21 +00:00
const oldSendStateByConversationId =
message . get ( 'sendStateByConversationId' ) || { } ;
const newSendStateByConversationId = Object . keys (
oldSendStateByConversationId
) . reduce ( ( acc , conversationId ) = > {
const sendState = sentConversationIds . get ( conversationId ) ;
if ( sendState ) {
return {
. . . acc ,
[ conversationId ] : sendState ,
} ;
}
return acc ;
} , { } as SendStateByConversationId ) ;
if ( isEqual ( oldSendStateByConversationId , newSendStateByConversationId ) ) {
return ;
}
message . set ( 'sendStateByConversationId' , newSendStateByConversationId ) ;
return window . Signal . Data . saveMessage ( message . attributes , {
ourUuid : window.textsecure.storage.user.getCheckedUuid ( ) . toString ( ) ,
} ) ;
2022-08-02 19:31:55 +00:00
} )
) ;
2022-08-09 03:26:21 +00:00
// Remove any unsent recipients
recipientsByUuid . forEach ( ( _value , uuid ) = > {
if ( sentUuids . has ( uuid ) ) {
return ;
}
recipientsByUuid . delete ( uuid ) ;
} ) ;
// Build up the sync message's storyMessageRecipients and send it
2022-08-02 19:31:55 +00:00
const storyMessageRecipients : Array < {
destinationUuid : string ;
distributionListIds : Array < string > ;
isAllowedToReply : boolean ;
} > = [ ] ;
recipientsByUuid . forEach ( ( distributionListIds , destinationUuid ) = > {
storyMessageRecipients . push ( {
destinationUuid ,
distributionListIds : Array.from ( distributionListIds ) ,
isAllowedToReply : canReplyUuids.has ( destinationUuid ) ,
} ) ;
} ) ;
const options = await getSendOptions ( conversation . attributes , {
syncMessage : true ,
} ) ;
messaging . sendSyncMessage ( {
destination : conversation.get ( 'e164' ) ,
destinationUuid : conversation.get ( 'uuid' ) ,
storyMessage : originalStoryMessage ,
storyMessageRecipients ,
expirationStartTimestamp : null ,
isUpdate : isSyncMessageUpdate ,
options ,
timestamp ,
urgent : false ,
} ) ;
}
function getMessageRecipients ( {
log ,
message ,
} : Readonly < {
log : LoggerType ;
message : MessageModel ;
} > ) : {
2022-08-09 03:26:21 +00:00
allRecipientIds : Array < string > ;
2022-08-02 19:31:55 +00:00
allowedReplyByUuid : Map < string , boolean > ;
2022-08-09 03:26:21 +00:00
pendingSendRecipientIds : Array < string > ;
sentRecipientIds : Array < string > ;
2022-08-02 19:31:55 +00:00
untrustedUuids : Array < string > ;
} {
2022-08-09 03:26:21 +00:00
const allRecipientIds : Array < string > = [ ] ;
2022-08-02 19:31:55 +00:00
const allowedReplyByUuid = new Map < string , boolean > ( ) ;
2022-08-09 03:26:21 +00:00
const pendingSendRecipientIds : Array < string > = [ ] ;
const sentRecipientIds : Array < string > = [ ] ;
const untrustedUuids : Array < string > = [ ] ;
2022-08-02 19:31:55 +00:00
Object . entries ( message . get ( 'sendStateByConversationId' ) || { } ) . forEach (
( [ recipientConversationId , sendState ] ) = > {
const recipient = window . ConversationController . get (
recipientConversationId
) ;
if ( ! recipient ) {
return ;
}
const isRecipientMe = isMe ( recipient . attributes ) ;
2022-08-09 03:26:21 +00:00
if ( isRecipientMe ) {
return ;
}
2022-08-02 19:31:55 +00:00
if ( recipient . isUntrusted ( ) ) {
const uuid = recipient . get ( 'uuid' ) ;
if ( ! uuid ) {
log . error (
` stories.sendStory/getMessageRecipients: Untrusted conversation ${ recipient . idForLogging ( ) } missing UUID. `
) ;
return ;
}
untrustedUuids . push ( uuid ) ;
return ;
}
if ( recipient . isUnregistered ( ) ) {
return ;
}
2022-08-09 03:26:21 +00:00
const recipientSendTarget = recipient . getSendTarget ( ) ;
if ( ! recipientSendTarget ) {
2022-08-02 19:31:55 +00:00
return ;
}
allowedReplyByUuid . set (
2022-08-09 03:26:21 +00:00
recipientSendTarget ,
2022-08-02 19:31:55 +00:00
Boolean ( sendState . isAllowedToReplyToStory )
) ;
2022-08-09 03:26:21 +00:00
allRecipientIds . push ( recipientSendTarget ) ;
2022-08-02 19:31:55 +00:00
2022-08-09 03:26:21 +00:00
if ( sendState . isAlreadyIncludedInAnotherDistributionList ) {
2022-08-02 19:31:55 +00:00
return ;
}
2022-08-09 03:26:21 +00:00
if ( isSent ( sendState . status ) ) {
sentRecipientIds . push ( recipientSendTarget ) ;
return ;
2022-08-02 19:31:55 +00:00
}
2022-08-09 03:26:21 +00:00
pendingSendRecipientIds . push ( recipientSendTarget ) ;
2022-08-02 19:31:55 +00:00
}
) ;
return {
2022-08-09 03:26:21 +00:00
allRecipientIds ,
2022-08-02 19:31:55 +00:00
allowedReplyByUuid ,
2022-08-09 03:26:21 +00:00
pendingSendRecipientIds ,
sentRecipientIds ,
2022-08-02 19:31:55 +00:00
untrustedUuids ,
} ;
}
async function markMessageFailed (
message : MessageModel ,
errors : Array < Error >
) : Promise < void > {
message . markFailed ( ) ;
message . saveErrors ( errors , { skipSave : true } ) ;
await window . Signal . Data . saveMessage ( message . attributes , {
ourUuid : window.textsecure.storage.user.getCheckedUuid ( ) . toString ( ) ,
} ) ;
}
function didSendToEveryone ( message : Readonly < MessageModel > ) : boolean {
const sendStateByConversationId =
message . get ( 'sendStateByConversationId' ) || { } ;
2022-08-09 03:26:21 +00:00
return Object . values ( sendStateByConversationId ) . every (
sendState = >
sendState . isAlreadyIncludedInAnotherDistributionList ||
isSent ( sendState . status )
2022-08-02 19:31:55 +00:00
) ;
}