Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchiveTSOutgoingMessageArchiver.swift
1 views
//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import LibSignalClient

class BackupArchiveTSOutgoingMessageArchiver {
    private typealias ArchiveFrameError = BackupArchive.ArchiveFrameError<BackupArchive.InteractionUniqueId>
    private typealias RestoreFrameError = BackupArchive.RestoreFrameError<BackupArchive.ChatItemId>

    private let contentsArchiver: BackupArchiveTSMessageContentsArchiver
    private let editHistoryArchiver: BackupArchiveTSMessageEditHistoryArchiver<TSOutgoingMessage>
    private let editMessageStore: EditMessageStore
    private let interactionStore: BackupArchiveInteractionStore
    private let pinnedMessageManager: PinnedMessageManager

    init(
        contentsArchiver: BackupArchiveTSMessageContentsArchiver,
        editMessageStore: EditMessageStore,
        interactionStore: BackupArchiveInteractionStore,
        pinnedMessageManager: PinnedMessageManager,
    ) {
        self.contentsArchiver = contentsArchiver
        self.editHistoryArchiver = BackupArchiveTSMessageEditHistoryArchiver(
            editMessageStore: editMessageStore,
        )
        self.editMessageStore = editMessageStore
        self.interactionStore = interactionStore
        self.pinnedMessageManager = pinnedMessageManager
    }

    // MARK: - Archiving

    func archiveOutgoingMessage(
        _ outgoingMessage: TSOutgoingMessage,
        threadInfo: BackupArchive.ChatArchivingContext.CachedThreadInfo,
        context: BackupArchive.ChatArchivingContext,
    ) -> BackupArchive.ArchiveInteractionResult<Details> {
        var partialErrors = [ArchiveFrameError]()

        let outgoingMessageDetails: Details
        switch editHistoryArchiver.archiveMessageAndEditHistory(
            outgoingMessage,
            threadInfo: threadInfo,
            context: context,
            builder: self,
        ).bubbleUp(Details.self, partialErrors: &partialErrors) {
        case .continue(let _outgoingMessageDetails):
            outgoingMessageDetails = _outgoingMessageDetails
        case .bubbleUpError(let errorResult):
            return errorResult
        }

        if partialErrors.isEmpty {
            return .success(outgoingMessageDetails)
        } else {
            return .partialFailure(outgoingMessageDetails, partialErrors)
        }
    }

    // MARK: - Restoring

    func restoreChatItem(
        _ topLevelChatItem: BackupProto_ChatItem,
        chatThread: BackupArchive.ChatThread,
        context: BackupArchive.ChatItemRestoringContext,
    ) -> BackupArchive.RestoreInteractionResult<Void> {
        var partialErrors = [RestoreFrameError]()

        switch editHistoryArchiver
            .restoreMessageAndEditHistory(
                topLevelChatItem,
                chatThread: chatThread,
                context: context,
                builder: self,
            )
            .bubbleUp(Void.self, partialErrors: &partialErrors)
        {
        case .continue:
            break
        case .bubbleUpError(let error):
            return error
        }

        if partialErrors.isEmpty {
            return .success(())
        } else {
            return .partialRestore((), partialErrors)
        }
    }
}

// MARK: -

private extension BackupArchive {
    /// Maps cases in ``BackupProto_SendStatus/Failed/FailureReason`` to raw
    /// error codes used in ``TSOutgoingMessageRecipientState/errorCode``.
    enum SendStatusFailureErrorCode: Int {
        /// Derived from ``OWSErrorCode/untrustedIdentity``, which is itself
        /// used in ``UntrustedIdentityError``.
        case identityKeyMismatch = 777427

        /// ``TSOutgoingMessageRecipientState/errorCode`` can contain literally
        /// the error code of any error thrown during message sending. To that
        /// end, we don't know what persisted error codes refer, now or in the
        /// past, to a network error. However, we want to be able to export
        /// network errors that we previously restored from a backup.
        ///
        /// This case serves as a sentinel value for network errors restored
        /// from a backup, so we can round-trip export them as network errors.
        ///
        /// - SeeAlso ``MessageSender``
        case networkError = 123456

        /// Derived from ``OWSErrorCode/genericFailure``.
        case unknown = 32

        /// Non-failable init where unknown raw values are coerced into
        /// `.unknown`.
        init(rawValue: Int) {
            switch rawValue {
            case SendStatusFailureErrorCode.identityKeyMismatch.rawValue:
                self = .identityKeyMismatch
            case SendStatusFailureErrorCode.networkError.rawValue:
                self = .networkError
            default:
                self = .unknown
            }
        }
    }
}

// MARK: - BackupArchive.TSMessageEditHistory.Builder

extension BackupArchiveTSOutgoingMessageArchiver: BackupArchive.TSMessageEditHistory.Builder {
    typealias MessageType = TSOutgoingMessage

    // MARK: - Archiving

    func buildMessageArchiveDetails(
        message outgoingMessage: MessageType,
        editRecord: EditRecord?,
        threadInfo: BackupArchive.ChatArchivingContext.CachedThreadInfo,
        context: BackupArchive.ChatArchivingContext,
    ) -> BackupArchive.ArchiveInteractionResult<Details> {
        var partialErrors = [ArchiveFrameError]()

        let wasAnySendSealedSender: Bool
        let outgoingDetails: BackupProto_ChatItem.OutgoingMessageDetails
        switch buildOutgoingMessageDetails(
            outgoingMessage,
            recipientContext: context.recipientContext,
        ).bubbleUp(Details.self, partialErrors: &partialErrors) {
        case .continue(let (_outgoingDetails, _wasAnySendSealedSender)):
            outgoingDetails = _outgoingDetails
            wasAnySendSealedSender = _wasAnySendSealedSender
        case .bubbleUpError(let errorResult):
            return errorResult
        }

        let chatItemType: BackupArchive.InteractionArchiveDetails.ChatItemType
        switch contentsArchiver.archiveMessageContents(
            outgoingMessage,
            context: context,
        ).bubbleUp(Details.self, partialErrors: &partialErrors) {
        case .continue(let t):
            chatItemType = t
        case .bubbleUpError(let errorResult):
            return errorResult
        }

        let expireStartDate: UInt64?
        if outgoingMessage.expireStartedAt > 0 {
            expireStartDate = outgoingMessage.expireStartedAt
        } else {
            expireStartDate = nil
        }

        guard let interactionRowId = outgoingMessage.sqliteRowId else {
            return .completeFailure(.fatalArchiveError(
                .fetchedInteractionMissingRowId,
            ))
        }

        let pinMessageDetails = pinnedMessageManager.pinMessageDetails(
            interactionId: interactionRowId,
            tx: context.tx,
        )

        let detailsResult = Details.validateAndBuild(
            interactionUniqueId: outgoingMessage.uniqueInteractionId,
            author: .localUser,
            directionalDetails: .outgoing(outgoingDetails),
            dateCreated: outgoingMessage.timestamp,
            expireStartDate: expireStartDate,
            expiresInMs: UInt64(outgoingMessage.expiresInSeconds) * 1000,
            isSealedSender: wasAnySendSealedSender,
            chatItemType: chatItemType,
            isSmsPreviouslyRestoredFromBackup: outgoingMessage.isSmsMessageRestoredFromBackup,
            threadInfo: threadInfo,
            pinMessageDetails: pinMessageDetails,
            context: context.recipientContext,
        )

        let details: Details
        switch detailsResult.bubbleUp(Details.self, partialErrors: &partialErrors) {
        case .continue(let _details):
            details = _details
        case .bubbleUpError(let error):
            return error
        }

        if partialErrors.isEmpty {
            return .success(details)
        } else {
            return .partialFailure(details, partialErrors)
        }
    }

    private func buildOutgoingMessageDetails(
        _ message: TSOutgoingMessage,
        recipientContext: BackupArchive.RecipientArchivingContext,
    ) -> BackupArchive.ArchiveInteractionResult<(
        outgoingDetails: BackupProto_ChatItem.OutgoingMessageDetails,
        wasAnySendSealedSender: Bool
    )> {
        var perRecipientErrors = [ArchiveFrameError]()

        var wasAnySendSealedSender = false
        var outgoingDetails = BackupProto_ChatItem.OutgoingMessageDetails()
        outgoingDetails.dateReceived = message.receivedAtTimestamp

        for (address, sendState) in message.recipientAddressStates ?? [:] {
            guard let recipientAddress = address.asSingleServiceIdBackupAddress()?.asArchivingAddress() else {
                perRecipientErrors.append(.archiveFrameError(
                    .invalidOutgoingMessageRecipient,
                    message.uniqueInteractionId,
                ))
                continue
            }
            guard let recipientId = recipientContext[recipientAddress] else {
                perRecipientErrors.append(.archiveFrameError(
                    .referencedRecipientIdMissing(recipientAddress),
                    message.uniqueInteractionId,
                ))
                continue
            }

            let deliveryStatus: BackupProto_SendStatus.OneOf_DeliveryStatus
            switch sendState.status {
            case .sent:
                var sentStatus = BackupProto_SendStatus.Sent()
                sentStatus.sealedSender = sendState.wasSentByUD

                deliveryStatus = .sent(sentStatus)
            case .delivered:
                var deliveredStatus = BackupProto_SendStatus.Delivered()
                deliveredStatus.sealedSender = sendState.wasSentByUD

                deliveryStatus = .delivered(deliveredStatus)
            case .read:
                var readStatus = BackupProto_SendStatus.Read()
                readStatus.sealedSender = sendState.wasSentByUD

                deliveryStatus = .read(readStatus)
            case .viewed:
                var viewedStatus = BackupProto_SendStatus.Viewed()
                viewedStatus.sealedSender = sendState.wasSentByUD

                deliveryStatus = .viewed(viewedStatus)
            case .failed:
                var failedStatus = BackupProto_SendStatus.Failed()
                failedStatus.reason = { () -> BackupProto_SendStatus.Failed.FailureReason in
                    guard let errorCode = sendState.errorCode else {
                        return .unknown
                    }

                    switch BackupArchive.SendStatusFailureErrorCode(rawValue: errorCode) {
                    case .unknown:
                        return .unknown
                    case .networkError:
                        return .network
                    case .identityKeyMismatch:
                        return .identityKeyMismatch
                    }
                }()

                deliveryStatus = .failed(failedStatus)
            case .sending, .pending:
                deliveryStatus = .pending(BackupProto_SendStatus.Pending())
            case .skipped:
                deliveryStatus = .skipped(BackupProto_SendStatus.Skipped())
            }

            var sendStatus = BackupProto_SendStatus()
            sendStatus.recipientID = recipientId.value
            sendStatus.timestamp = sendState.statusTimestamp
            sendStatus.deliveryStatus = deliveryStatus

            outgoingDetails.sendStatus.append(sendStatus)

            if sendState.wasSentByUD {
                wasAnySendSealedSender = true
            }
        }

        if perRecipientErrors.isEmpty {
            return .success((
                outgoingDetails: outgoingDetails,
                wasAnySendSealedSender: wasAnySendSealedSender,
            ))
        } else {
            return .partialFailure(
                (
                    outgoingDetails: outgoingDetails,
                    wasAnySendSealedSender: wasAnySendSealedSender,
                ),
                perRecipientErrors,
            )
        }
    }

    // MARK: - Restoring

    /// An error representing a `TSMessage` failing to insert, since
    /// ``TSMessage/anyInsert`` fails silently.
    private struct MessageInsertionError: Error {}

    func restoreMessage(
        _ chatItem: BackupProto_ChatItem,
        revisionType: BackupArchive.TSMessageEditHistory.RevisionType<MessageType>,
        chatThread: BackupArchive.ChatThread,
        context: BackupArchive.ChatItemRestoringContext,
    ) -> BackupArchive.RestoreInteractionResult<MessageType> {
        guard let chatItemType = chatItem.item else {
            return .unrecognizedEnum(BackupArchive.UnrecognizedEnumError(
                enumType: BackupProto_ChatItem.OneOf_Item.self,
            ))
        }

        let outgoingDetails: BackupProto_ChatItem.OutgoingMessageDetails
        switch chatItem.directionalDetails {
        case .outgoing(let _outgoingDetails):
            outgoingDetails = _outgoingDetails
        case .incoming, .directionless:
            return .messageFailure([.restoreFrameError(
                .invalidProtoData(.revisionOfOutgoingMessageMissingOutgoingDetails),
                chatItem.id,
            )])
        case nil:
            return .unrecognizedEnum(BackupArchive.UnrecognizedEnumError(
                enumType: BackupProto_ChatItem.OneOf_DirectionalDetails.self,
            ))
        }

        var partialErrors = [RestoreFrameError]()

        let contents: BackupArchive.RestoredMessageContents
        switch contentsArchiver
            .restoreContents(
                chatItemType,
                chatItemId: chatItem.id,
                chatThread: chatThread,
                context: context,
            )
            .bubbleUp(
                MessageType.self,
                partialErrors: &partialErrors,
            )
        {
        case .continue(let component):
            contents = component
        case .bubbleUpError(let error):
            return error
        }

        let editState: TSEditState
        switch revisionType {
        case .latestRevision(hasPastRevisions: false):
            editState = .none
        case .latestRevision(hasPastRevisions: true):
            // Outgoing messages are implicitly read.
            editState = .latestRevisionRead
        case .pastRevision:
            editState = .pastRevision
        }

        let outgoingMessage: TSOutgoingMessage
        switch self
            .restoreAndInsertOutgoingMessage(
                chatItem: chatItem,
                contents: contents,
                outgoingDetails: outgoingDetails,
                editState: editState,
                context: context,
                chatThread: chatThread,
            )
            .bubbleUp(
                MessageType.self,
                partialErrors: &partialErrors,
            )
        {
        case .continue(let component):
            outgoingMessage = component
        case .bubbleUpError(let error):
            return error
        }

        switch contentsArchiver
            .restoreDownstreamObjects(
                message: outgoingMessage,
                thread: chatThread,
                chatItemId: chatItem.id,
                pinDetails: chatItem.hasPinDetails ? chatItem.pinDetails : nil,
                restoredContents: contents,
                context: context,
            )
            .bubbleUp(
                MessageType.self,
                partialErrors: &partialErrors,
            )
        {
        case .continue:
            break
        case .bubbleUpError(let error):
            return error
        }

        do {
            let editRecord: EditRecord?
            switch revisionType {
            case .latestRevision:
                editRecord = nil
            case .pastRevision(let latestRevisionMessage):
                // Outgoing messages, and their edits, are implicitly read.
                editRecord = EditRecord(
                    latestRevisionId: latestRevisionMessage.sqliteRowId!,
                    pastRevisionId: outgoingMessage.sqliteRowId!,
                    read: true,
                )
            }

            if let editRecord {
                try editMessageStore.insert(editRecord, tx: context.tx)
            }
        } catch {
            return .partialRestore(
                outgoingMessage,
                [.restoreFrameError(
                    .databaseInsertionFailed(error),
                    chatItem.id,
                )] + partialErrors,
            )
        }

        if partialErrors.isEmpty {
            return .success(outgoingMessage)
        } else {
            return .partialRestore(outgoingMessage, partialErrors)
        }
    }

    private func restoreAndInsertOutgoingMessage(
        chatItem: BackupProto_ChatItem,
        contents: BackupArchive.RestoredMessageContents,
        outgoingDetails: BackupProto_ChatItem.OutgoingMessageDetails,
        editState: TSEditState,
        context: BackupArchive.ChatItemRestoringContext,
        chatThread: BackupArchive.ChatThread,
    ) -> BackupArchive.RestoreInteractionResult<TSOutgoingMessage> {
        // We don't _really_ need to check the upper limit here because
        // its enforced by the validator, but it doesn't hurt.
        guard SDS.fitsInInt64(chatItem.dateSent), chatItem.dateSent > 0 else {
            return .messageFailure([.restoreFrameError(
                .invalidProtoData(.chatItemInvalidDateSent),
                chatItem.id,
            )])
        }

        let expiresInSeconds: UInt32
        if chatItem.hasExpiresInMs {
            guard let _expiresInSeconds: UInt32 = .msToSecs(chatItem.expiresInMs) else {
                return .messageFailure([.restoreFrameError(
                    .invalidProtoData(.expirationTimerOverflowedLocalType),
                    chatItem.id,
                )])
            }
            expiresInSeconds = _expiresInSeconds
        } else {
            // 0 == no expiration
            expiresInSeconds = 0
        }

        var partialErrors = [RestoreFrameError]()

        var recipientAddressStates = [BackupArchive.InteropAddress: TSOutgoingMessageRecipientState]()
        for sendStatus in outgoingDetails.sendStatus {
            let recipientAddress: BackupArchive.InteropAddress
            let recipientID = sendStatus.destinationRecipientId
            switch context.recipientContext[recipientID] {
            case .contact(let address):
                recipientAddress = address.asInteropAddress()
            case .localAddress:
                recipientAddress = context.recipientContext.localIdentifiers.aciAddress
            case .none:
                // Missing recipient! Fail this one recipient but keep going.
                partialErrors.append(.restoreFrameError(
                    .invalidProtoData(.recipientIdNotFound(recipientID)),
                    chatItem.id,
                ))
                continue
            case .group, .distributionList, .releaseNotesChannel, .callLink:
                // Recipients can only be contacts.
                partialErrors.append(.restoreFrameError(
                    .invalidProtoData(.outgoingNonContactMessageRecipient),
                    chatItem.id,
                ))
                continue
            }

            guard
                let recipientState = recipientState(
                    for: sendStatus,
                    partialErrors: &partialErrors,
                    chatItemId: chatItem.id,
                )
            else {
                continue
            }

            recipientAddressStates[recipientAddress] = recipientState
        }

        if recipientAddressStates.isEmpty, outgoingDetails.sendStatus.isEmpty.negated {
            // We put up with some failures, but if we get no recipients at all
            // fail the whole thing.
            return .messageFailure(partialErrors)
        }

        let expireStartedAt: UInt64
        if chatItem.hasExpireStartDate {
            expireStartedAt = chatItem.expireStartDate
        } else if
            expiresInSeconds > 0,
            TSOutgoingMessage.isEligibleToStartExpireTimer(recipientStates: Array(recipientAddressStates.values))
        {
            // If there is an expire timer and the message is eligible to start expiring,
            // set the expire start time to now even if unset in the proto.
            expireStartedAt = context.startDate.ows_millisecondsSince1970
        } else {
            expireStartedAt = 0
        }

        let outgoingMessageResult: BackupArchive.RestoreInteractionResult<TSOutgoingMessage> = {
            /// A "base" message builder, onto which we attach the data we
            /// unwrap from `contents`.
            let outgoingMessageBuilder = TSOutgoingMessageBuilder(
                thread: chatThread.tsThread,
                timestamp: chatItem.dateSent,
                receivedAtTimestamp: outgoingDetails.dateReceived > 0
                    ? outgoingDetails.dateReceived
                    // If we pass `nil` this will default to "now", which is a much
                    // worse approximation than the "sent" timestamp. For outgoing
                    // messages, "sent" and "received" are the same, anyway.
                    : chatItem.dateSent,
                messageBody: nil,
                editState: editState,
                expiresInSeconds: expiresInSeconds,
                // Backed up messages don't set the chat timer; version is irrelevant.
                expireTimerVersion: nil,
                expireStartedAt: expireStartedAt,
                isVoiceMessage: false,
                isSmsMessageRestoredFromBackup: chatItem.sms,
                isViewOnceMessage: false,
                isViewOnceComplete: false,
                wasRemotelyDeleted: false,
                wasNotCreatedLocally: true,
                groupChangeProtoData: nil,
                // We never restore stories.
                storyAuthorAci: nil,
                storyTimestamp: nil,
                storyReactionEmoji: nil,
                quotedMessage: nil,
                contactShare: nil,
                linkPreview: nil,
                messageSticker: nil,
                giftBadge: nil,
                isPoll: false,
            )

            switch contents {
            case .archivedPayment(let archivedPayment):
                return .success(OWSOutgoingArchivedPaymentMessage(
                    outgoingArchivedPaymentMessageWith: outgoingMessageBuilder,
                    amount: archivedPayment.amount,
                    fee: archivedPayment.fee,
                    note: archivedPayment.note,
                    recipientAddressStates: recipientAddressStates,
                ))
            case .remoteDeleteTombstone:
                outgoingMessageBuilder.wasRemotelyDeleted = true
            case .text(let text):
                outgoingMessageBuilder.setMessageBody(text.body)
                outgoingMessageBuilder.quotedMessage = text.quotedMessage
                outgoingMessageBuilder.linkPreview = text.linkPreview
                outgoingMessageBuilder.isVoiceMessage = text.isVoiceMessage
            case .contactShare(let contactShare):
                outgoingMessageBuilder.contactShare = contactShare.contact
            case .stickerMessage(let stickerMessage):
                outgoingMessageBuilder.messageSticker = stickerMessage.sticker
            case .giftBadge(let giftBadge):
                outgoingMessageBuilder.giftBadge = giftBadge.giftBadge
            case .viewOnceMessage(let viewOnceMessage):
                outgoingMessageBuilder.isViewOnceMessage = true
                switch viewOnceMessage.state {
                case .unviewed:
                    outgoingMessageBuilder.isViewOnceComplete = false
                case .complete:
                    outgoingMessageBuilder.isViewOnceComplete = true
                }
            case .storyReply(let storyReply):
                switch storyReply.replyType {
                case .textReply(let textReply):
                    outgoingMessageBuilder.setMessageBody(textReply.body)
                case .emoji(let emoji):
                    outgoingMessageBuilder.storyReactionEmoji = emoji
                }
                // We can't reply to our own stories; if a 1:1 story reply is outgoing
                // that means the author of the story being replied to was the peer.
                switch chatThread.threadType {
                case .contact(let contactThread):
                    guard let aci = contactThread.contactAddress.aci else {
                        return .messageFailure(
                            [.restoreFrameError(.invalidProtoData(.directStoryReplyFromNonAci), chatItem.id)]
                                + partialErrors,
                        )
                    }
                    outgoingMessageBuilder.storyAuthorAci = AciObjC(aci)
                case .groupV2:
                    return .messageFailure(
                        [.restoreFrameError(.invalidProtoData(.directStoryReplyInGroupThread), chatItem.id)]
                            + partialErrors,
                    )
                }
            case .poll(let poll):
                outgoingMessageBuilder.isPoll = true
                outgoingMessageBuilder.setMessageBody(poll.question)
            case .adminDeleteTombstone:
                outgoingMessageBuilder.wasRemotelyDeleted = true
            }

            return .success(TSOutgoingMessage(
                outgoingMessageWith: outgoingMessageBuilder,
                recipientAddressStates: recipientAddressStates,
            ))
        }()

        let outgoingMessage: TSOutgoingMessage
        switch outgoingMessageResult.bubbleUp(TSOutgoingMessage.self, partialErrors: &partialErrors) {
        case .continue(let component):
            outgoingMessage = component
        case .bubbleUpError(let error):
            return error
        }

        do {
            try interactionStore.insert(
                outgoingMessage,
                in: chatThread,
                chatId: chatItem.typedChatId,
                context: context,
            )
        } catch let error {
            return .messageFailure(partialErrors + [.restoreFrameError(.databaseInsertionFailed(error), chatItem.id)])
        }

        guard outgoingMessage.sqliteRowId != nil else {
            // Failed insert!
            return .messageFailure(partialErrors + [.restoreFrameError(
                .databaseInsertionFailed(MessageInsertionError()),
                chatItem.id,
            )])
        }

        if partialErrors.isEmpty {
            return .success(outgoingMessage)
        } else {
            return .partialRestore(outgoingMessage, partialErrors)
        }
    }

    private func recipientState(
        for sendStatus: BackupProto_SendStatus,
        partialErrors: inout [RestoreFrameError],
        chatItemId: BackupArchive.ChatItemId,
    ) -> TSOutgoingMessageRecipientState? {
        let recipientStatus: OWSOutgoingMessageRecipientStatus
        var wasSentByUD: Bool = false
        var errorCode: Int?
        switch sendStatus.deliveryStatus {
        case nil:
            // Fallback to pending
            recipientStatus = .pending
        case .pending:
            recipientStatus = .pending
        case .sent(let sent):
            recipientStatus = .sent
            wasSentByUD = sent.sealedSender
        case .delivered(let delivered):
            recipientStatus = .delivered
            wasSentByUD = delivered.sealedSender
        case .read(let read):
            recipientStatus = .read
            wasSentByUD = read.sealedSender
        case .viewed(let viewed):
            recipientStatus = .viewed
            wasSentByUD = viewed.sealedSender
        case .skipped:
            recipientStatus = .skipped
        case .failed(let failed):
            let failureErrorCode: BackupArchive.SendStatusFailureErrorCode = {
                switch failed.reason {
                case .UNRECOGNIZED, .unknown: return .unknown
                case .identityKeyMismatch: return .identityKeyMismatch
                case .network: return .networkError
                }
            }()

            recipientStatus = .failed
            errorCode = failureErrorCode.rawValue
        }

        return TSOutgoingMessageRecipientState(
            status: recipientStatus,
            statusTimestamp: sendStatus.timestamp,
            wasSentByUD: wasSentByUD,
            errorCode: errorCode,
        )
    }
}