// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only

/* eslint-disable no-bitwise */

import lodash from 'lodash';
import PQueue from 'p-queue';
import { v7 as getGuid } from 'uuid';

import type {
  SenderCertificate,
  UnidentifiedSenderMessageContent,
} from '@signalapp/libsignal-client';
import {
  CiphertextMessageType,
  ContentHint,
  DecryptionErrorMessage,
  ErrorCode as LibSignalErrorCode,
  groupDecrypt,
  LibSignalErrorBase,
  PlaintextContent,
  Pni,
  PreKeySignalMessage,
  processSenderKeyDistributionMessage,
  ProtocolAddress,
  PublicKey,
  sealedSenderDecryptToUsmc,
  SenderKeyDistributionMessage,
  signalDecrypt,
  signalDecryptPreKey,
  SignalMessage,
} from '@signalapp/libsignal-client';

import {
  IdentityKeys,
  KyberPreKeys,
  PreKeys,
  SenderKeys,
  Sessions,
  SignedPreKeys,
} from '../LibSignalStores.preload.js';
import { createName } from '../util/attachmentPath.node.js';
import { assertDev, strictAssert } from '../util/assert.std.js';
import type { BatcherType } from '../util/batcher.std.js';
import { createBatcher } from '../util/batcher.std.js';
import { drop } from '../util/drop.std.js';
import { dropNull } from '../util/dropNull.std.js';
import { parseIntOrThrow } from '../util/parseIntOrThrow.std.js';
import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary.std.js';
import { Zone } from '../util/Zone.std.js';
import * as durations from '../util/durations/index.std.js';
import { DurationInSeconds } from '../util/durations/index.std.js';
import { isKnownProtoEnumMember } from '../util/isKnownProtoEnumMember.std.js';
import { Address } from '../types/Address.std.js';
import { QualifiedAddress } from '../types/QualifiedAddress.std.js';
import { normalizeStoryDistributionId } from '../types/StoryDistributionId.std.js';
import type { ServiceIdString, AciString } from '../types/ServiceId.std.js';
import type { TextAttachmentType } from '../types/Attachment.std.js';
import {
  fromPniObject,
  isPniString,
  isServiceIdString,
  isUntaggedPniString,
  normalizePni,
  normalizeServiceId,
  ServiceIdKind,
  toTaggedPni,
} from '../types/ServiceId.std.js';
import { normalizeAci } from '../util/normalizeAci.std.js';
import { isAciString } from '../util/isAciString.std.js';
import { calling } from '../services/calling.preload.js';
import { retryPlaceholders } from '../services/retryPlaceholders.std.js';
import * as Errors from '../types/errors.std.js';
import { signalProtocolStore } from '../SignalProtocolStore.preload.js';

import { SignalService as Proto } from '../protobuf/index.std.js';
import { deriveGroupFields, MASTER_KEY_LENGTH } from '../groups.preload.js';

import createTaskWithTimeout from './TaskWithTimeout.std.js';
import {
  processAttachment,
  processDataMessage,
  processBodyRange,
  processGroupV2Context,
  processPreview,
} from './processDataMessage.preload.js';
import { processSent } from './processSyncMessage.node.js';
import type { EventHandler } from './EventTarget.std.js';
import EventTarget from './EventTarget.std.js';
import type { IncomingWebSocketRequest } from './WebsocketResources.preload.js';
import { ServerRequestType } from './WebsocketResources.preload.js';
import { type Storage } from './Storage.preload.js';
import { accountManager } from './AccountManager.preload.js';
import { WarnOnlyError } from './Errors.std.js';
import * as Bytes from '../Bytes.std.js';
import type {
  IRequestHandler,
  ProcessedAttachment,
  ProcessedDataMessage,
  ProcessedEnvelope,
  ProcessedPreview,
  ProcessedSent,
  UnprocessedType,
} from './Types.d.ts';
import type {
  ConversationIdentifier,
  DeleteForMeSyncEventData,
  AttachmentBackfillAttachmentType,
  AttachmentBackfillResponseSyncEventData,
  DeleteForMeSyncTarget,
  AddressableMessage,
  ReadSyncEventData,
  ViewSyncEventData,
} from './messageReceiverEvents.std.js';
import {
  AttachmentBackfillResponseSyncEvent,
  CallEventSyncEvent,
  CallLinkUpdateSyncEvent,
  CallLogEventSyncEvent,
  ConfigurationEvent,
  ContactSyncEvent,
  DecryptionErrorEvent,
  DeleteForMeSyncEvent,
  DeliveryEvent,
  DeviceNameChangeSyncEvent,
  EmptyEvent,
  EnvelopeQueuedEvent,
  EnvelopeUnsealedEvent,
  ErrorEvent,
  FetchLatestEvent,
  InvalidPlaintextEvent,
  KeysEvent,
  MessageEvent,
  MessageRequestResponseEvent,
  ProfileKeyUpdateEvent,
  ReadEvent,
  ReadSyncEvent,
  RetryRequestEvent,
  SentEvent,
  StickerPackEvent,
  StoryRecipientUpdateEvent,
  SuccessfulDecryptEvent,
  TypingEvent,
  ViewEvent,
  ViewOnceOpenSyncEvent,
  ViewSyncEvent,
} from './messageReceiverEvents.std.js';
import { createLogger } from '../logging/log.std.js';
import { diffArraysAsSets } from '../util/diffArraysAsSets.std.js';
import { generateBlurHash } from '../util/generateBlurHash.std.js';
import { TEXT_ATTACHMENT } from '../types/MIME.std.js';
import type { SendTypesType } from '../util/handleMessageSend.preload.js';
import { getStoriesBlocked } from '../util/stories.preload.js';
import { isNotNil } from '../util/isNotNil.std.js';
import { chunk } from '../util/iterables.std.js';
import { inspectUnknownFieldTags } from '../util/inspectProtobufs.std.js';
import { incrementMessageCounter } from '../util/incrementMessageCounter.preload.js';
import { filterAndClean } from '../util/BodyRange.node.js';
import {
  getCallEventForProto,
  getCallLogEventForProto,
} from '../util/callDisposition.preload.js';
import { checkOurPniIdentityKey } from '../util/checkOurPniIdentityKey.preload.js';
import { CallLinkUpdateSyncType } from '../types/CallLink.std.js';
import { bytesToUuid } from '../util/uuidToBytes.std.js';
import { isBodyTooLong } from '../util/longAttachment.std.js';
import {
  fromServiceIdBinaryOrString,
  fromAciUuidBytes,
  fromAciUuidBytesOrString,
  fromPniUuidBytesOrUntaggedString,
} from '../util/ServiceId.node.js';
import {
  type MessageRequestResponseInfo,
  MessageRequestResponseSource,
} from '../types/MessageRequestResponseEvent.std.js';

import { toNumber } from '../util/toNumber.std.js';

const { isBoolean, isNumber, isString, noop } = lodash;

const log = createLogger('MessageReceiver');

const GROUPV2_ID_LENGTH = 32;
const RETRY_TIMEOUT = 2 * 60 * 1000;

type UnsealedEnvelope = Readonly<
  Omit<ProcessedEnvelope, 'sourceServiceId'> & {
    sourceServiceId: ServiceIdString;
    unidentifiedDeliveryReceived?: boolean;
    contentHint?: number;
    groupId?: string;
    cipherTextBytes?: Uint8Array<ArrayBuffer>;
    cipherTextType?: number;
    certificate?: SenderCertificate;
    unsealedContent?: UnidentifiedSenderMessageContent;
  }
>;

type DecryptResult = Readonly<
  | {
      envelope: UnsealedEnvelope;
      plaintext: Uint8Array<ArrayBuffer>;
    }
  | {
      envelope?: UnsealedEnvelope;
      plaintext?: undefined;
    }
>;

type DecryptSealedSenderResult = Readonly<{
  plaintext: Uint8Array<ArrayBuffer>;
  wasEncrypted: boolean;
}>;

type InnerDecryptResultType = Readonly<{
  plaintext: Uint8Array<ArrayBuffer>;
  wasEncrypted: boolean;
}>;

type CacheAddItemType = {
  envelope: ProcessedEnvelope;
  data: UnprocessedType;
  request: Pick<IncomingWebSocketRequest, 'respond'>;
};

type LockedStores = {
  readonly identityKeyStore: IdentityKeys;
  readonly kyberPreKeyStore: KyberPreKeys;
  readonly preKeyStore: PreKeys;
  readonly senderKeyStore: SenderKeys;
  readonly sessionStore: Sessions;
  readonly signedPreKeyStore: SignedPreKeys;

  readonly zone?: Zone;
};

enum TaskType {
  Encrypted = 'Encrypted',
  Decrypted = 'Decrypted',
}

export type MessageReceiverOptions = {
  storage: Storage;
  serverTrustRoots: Array<string>;
};

const TASK_WITH_TIMEOUT_OPTIONS = {
  timeout: 2 * durations.MINUTE,
};

const LOG_UNEXPECTED_URGENT_VALUES = false;
const MUST_BE_URGENT_TYPES: Array<SendTypesType> = [
  'message',
  'deleteForEveryone',
  'reaction',
  'readSync',
];
const CAN_BE_URGENT_TYPES: Array<SendTypesType> = [
  'callingMessage',
  'senderKeyDistributionMessage',

  // Deprecated
  'resetSession',
  'legacyGroupChange',
];

function logUnexpectedUrgentValue(
  envelope: ProcessedEnvelope,
  type: SendTypesType
) {
  if (!LOG_UNEXPECTED_URGENT_VALUES) {
    return;
  }

  const mustBeUrgent = MUST_BE_URGENT_TYPES.includes(type);
  const canBeUrgent = mustBeUrgent || CAN_BE_URGENT_TYPES.includes(type);

  if (envelope.urgent && !canBeUrgent) {
    const envelopeId = getEnvelopeId(envelope);
    log.warn(
      `${envelopeId}: Message of type '${type}' was marked urgent, but shouldn't be!`
    );
  }
  if (!envelope.urgent && mustBeUrgent) {
    const envelopeId = getEnvelopeId(envelope);
    log.warn(
      `${envelopeId}: Message of type '${type}' wasn't marked urgent, but should be!`
    );
  }
}

function getEnvelopeId(envelope: ProcessedEnvelope): string {
  const { timestamp } = envelope;

  let prefix = '';

  if (envelope.sourceServiceId || envelope.source) {
    const sender = envelope.sourceServiceId || envelope.source;
    prefix += `${sender}.${envelope.sourceDevice} `;
  }

  prefix += `> ${envelope.destinationServiceId}`;

  return `${prefix} ${timestamp} (${envelope.id})`;
}

/* eslint-disable @typescript-eslint/brace-style -- Prettier conflicts with ESLint */
export default class MessageReceiver
  extends EventTarget
  implements IRequestHandler
{
  /* eslint-enable @typescript-eslint/brace-style */

  #storage: Storage;

  #appQueue: PQueue;
  #decryptAndCacheBatcher: BatcherType<CacheAddItemType>;
  #cacheRemoveBatcher: BatcherType<string>;
  #processedCount: number;
  #incomingQueue: PQueue;
  #isEmptied?: boolean;
  #encryptedQueue: PQueue;
  #decryptedQueue: PQueue;
  #retryCachedTimeout: NodeJS.Timeout | undefined;
  #serverTrustRoots: Array<PublicKey>;
  #stoppingProcessing?: boolean;
  #pniIdentityKeyCheckRequired?: boolean;

  constructor({ storage, serverTrustRoots }: MessageReceiverOptions) {
    super();

    this.#storage = storage;

    this.#processedCount = 0;

    if (serverTrustRoots.length === 0) {
      throw new Error('Server trust root is required!');
    }
    this.#serverTrustRoots = serverTrustRoots.map(key =>
      PublicKey.deserialize(Bytes.fromBase64(key))
    );

    this.#incomingQueue = new PQueue({
      concurrency: 1,
      throwOnTimeout: true,
    });
    this.#appQueue = new PQueue({
      concurrency: 1,
      throwOnTimeout: true,
    });

    // All envelopes start in encryptedQueue and progress to decryptedQueue
    this.#encryptedQueue = new PQueue({
      concurrency: 1,
      throwOnTimeout: true,
    });
    this.#decryptedQueue = new PQueue({
      concurrency: 1,
      throwOnTimeout: true,
    });

    this.#decryptAndCacheBatcher = createBatcher<CacheAddItemType>({
      name: 'MessageReceiver.decryptAndCacheBatcher',
      wait: 75,
      maxSize: 30,
      processBatch: (items: Array<CacheAddItemType>) => {
        return this.#decryptAndCacheBatch(items);
      },
    });
    this.#cacheRemoveBatcher = createBatcher<string>({
      name: 'MessageReceiver.cacheRemoveBatcher',
      wait: 75,
      maxSize: 30,
      processBatch: this.#cacheRemoveBatch.bind(this),
    });
  }

  public getAndResetProcessedCount(): number {
    const count = this.#processedCount;
    this.#processedCount = 0;
    return count;
  }

  public handleRequest(request: IncomingWebSocketRequest): void {
    // We do the message decryption here, instead of in the ordered pending queue,
    // to avoid exposing the time it took us to process messages through the time-to-ack.
    log.info('got request', request.requestType);
    if (request.requestType !== ServerRequestType.ApiMessage) {
      request.respond(200, 'OK');

      if (request.requestType === ServerRequestType.ApiEmptyQueue) {
        drop(
          this.#incomingQueue.add(
            createTaskWithTimeout(
              async () => {
                this.#onEmpty();
              },
              'incomingQueue/onEmpty',
              TASK_WITH_TIMEOUT_OPTIONS
            )
          )
        );
      }
      return;
    }

    const job = async () => {
      try {
        if (!request.body) {
          throw new Error(
            'MessageReceiver.handleRequest: request.body was falsey!'
          );
        }

        const plaintext = request.body;

        const decoded = Proto.Envelope.decode(plaintext);
        const serverTimestamp = toNumber(decoded.serverTimestamp) ?? 0;

        const ourAci = this.#storage.user.getCheckedAci();

        const { content } = decoded;

        const envelope: ProcessedEnvelope = {
          // Make non-private envelope IDs dashless so they don't get redacted
          //   from logs
          id: getGuid().replace(/-/g, '.'),
          receivedAtCounter: incrementMessageCounter(),
          receivedAtDate: Date.now(),
          // Calculate the message age (time on server).
          messageAgeSec: this.#calculateMessageAge(
            request.timestamp,
            serverTimestamp
          ),

          // Proto.Envelope fields
          type: isKnownProtoEnumMember(Proto.Envelope.Type, decoded.type)
            ? decoded.type
            : Proto.Envelope.Type.UNKNOWN,
          source: undefined,
          sourceServiceId: fromServiceIdBinaryOrString(
            decoded.sourceServiceIdBinary,
            decoded.sourceServiceId,
            'MessageReceiver.handleRequest.sourceServiceId'
          ),
          sourceDevice: decoded.sourceDeviceId ?? 1,
          destinationServiceId:
            fromServiceIdBinaryOrString(
              decoded.destinationServiceIdBinary,
              decoded.destinationServiceId,
              'MessageReceiver.handleRequest.destinationServiceId'
            ) || ourAci,
          updatedPni: fromPniUuidBytesOrUntaggedString(
            decoded.updatedPniBinary,
            decoded.updatedPni,
            'MessageReceiver.handleRequest.updatedPni'
          ),
          timestamp: toNumber(decoded.clientTimestamp) ?? 0,
          content: content ?? new Uint8Array(0),
          serverGuid:
            (Bytes.isNotEmpty(decoded.serverGuidBinary)
              ? bytesToUuid(decoded.serverGuidBinary)
              : undefined) ??
            decoded.serverGuid ??
            getGuid(),
          serverTimestamp,
          urgent: isBoolean(decoded.urgent) ? decoded.urgent : true,
          story: decoded.story ?? false,
          reportingToken: Bytes.isNotEmpty(decoded.reportSpamToken)
            ? decoded.reportSpamToken
            : undefined,
          groupId: undefined,
        };

        // After this point, decoding errors are not the server's
        //   fault, and we should handle them gracefully and tell the
        //   user they received an invalid message

        this.#decryptAndCache(envelope, request);
        this.#processedCount += 1;
      } catch (e) {
        request.respond(500, 'Bad encrypted websocket message');
        log.error('Error handling incoming message:', Errors.toLogFormat(e));
        await this.#dispatchAndWait('websocket request', new ErrorEvent(e));
      }
    };

    drop(
      this.#incomingQueue.add(
        createTaskWithTimeout(
          job,
          'incomingQueue/websocket',
          TASK_WITH_TIMEOUT_OPTIONS
        )
      )
    );
  }

  public handleDisconnect(): void {
    this.#isEmptied = false;
  }

  public startProcessingQueue(): void {
    log.info('startProcessingQueue');
    this.#stoppingProcessing = false;

    drop(this.#addCachedMessagesToQueue());
  }

  #addCachedMessagesToQueue(): Promise<void> {
    log.info('addCachedMessagesToQueue');
    return this.#incomingQueue.add(
      createTaskWithTimeout(
        async () => this.#queueAllCached(),
        'incomingQueue/queueAllCached',
        {
          timeout: 10 * durations.MINUTE,
        }
      )
    );
  }

  public stopProcessing(): void {
    log.info('stopProcessing');
    this.#stoppingProcessing = true;
  }

  public hasEmptied(): boolean {
    return Boolean(this.#isEmptied);
  }

  public async drain(): Promise<void> {
    const waitForEncryptedQueue = async () =>
      this.#addToQueue(
        async () => {
          log.info('drained');
        },
        'drain/waitForDecrypted',
        TaskType.Decrypted
      );

    const waitForIncomingQueue = async () =>
      this.#addToQueue(
        waitForEncryptedQueue,
        'drain/waitForEncrypted',
        TaskType.Encrypted
      );

    return this.#incomingQueue.add(
      createTaskWithTimeout(
        waitForIncomingQueue,
        'drain/waitForIncoming',
        TASK_WITH_TIMEOUT_OPTIONS
      )
    );
  }

  //
  // EventTarget types
  //

  public override addEventListener(
    name: 'empty',
    handler: (ev: EmptyEvent) => void
  ): void;

  public override addEventListener(
    name: 'typing',
    handler: (ev: TypingEvent) => void
  ): void;

  public override addEventListener(
    name: 'error',
    handler: (ev: ErrorEvent) => void
  ): void;

  public override addEventListener(
    name: 'delivery',
    handler: (ev: DeliveryEvent) => void
  ): void;

  public override addEventListener(
    name: 'successful-decrypt',
    handler: (ev: SuccessfulDecryptEvent) => void
  ): void;

  public override addEventListener(
    name: 'decryption-error',
    handler: (ev: DecryptionErrorEvent) => void
  ): void;

  public override addEventListener(
    name: 'invalid-plaintext',
    handler: (ev: InvalidPlaintextEvent) => void
  ): void;

  public override addEventListener(
    name: 'sent',
    handler: (ev: SentEvent) => void
  ): void;

  public override addEventListener(
    name: 'profileKeyUpdate',
    handler: (ev: ProfileKeyUpdateEvent) => void
  ): void;

  public override addEventListener(
    name: 'message',
    handler: (ev: MessageEvent) => void
  ): void;

  public override addEventListener(
    name: 'retry-request',
    handler: (ev: RetryRequestEvent) => void
  ): void;

  public override addEventListener(
    name: 'read',
    handler: (ev: ReadEvent) => void
  ): void;

  public override addEventListener(
    name: 'view',
    handler: (ev: ViewEvent) => void
  ): void;

  public override addEventListener(
    name: 'configuration',
    handler: (ev: ConfigurationEvent) => void
  ): void;

  public override addEventListener(
    name: 'viewOnceOpenSync',
    handler: (ev: ViewOnceOpenSyncEvent) => void
  ): void;

  public override addEventListener(
    name: 'messageRequestResponse',
    handler: (ev: MessageRequestResponseEvent) => void
  ): void;

  public override addEventListener(
    name: 'fetchLatest',
    handler: (ev: FetchLatestEvent) => void
  ): void;

  public override addEventListener(
    name: 'keys',
    handler: (ev: KeysEvent) => void
  ): void;

  public override addEventListener(
    name: 'sticker-pack',
    handler: (ev: StickerPackEvent) => void
  ): void;

  public override addEventListener(
    name: 'readSync',
    handler: (ev: ReadSyncEvent) => void
  ): void;

  public override addEventListener(
    name: 'viewSync',
    handler: (ev: ViewSyncEvent) => void
  ): void;

  public override addEventListener(
    name: 'contactSync',
    handler: (ev: ContactSyncEvent) => void
  ): void;

  public override addEventListener(
    name: 'envelopeQueued',
    handler: (ev: EnvelopeQueuedEvent) => void
  ): void;

  public override addEventListener(
    name: 'envelopeUnsealed',
    handler: (ev: EnvelopeUnsealedEvent) => void
  ): void;

  public override addEventListener(
    name: 'storyRecipientUpdate',
    handler: (ev: StoryRecipientUpdateEvent) => void
  ): void;

  public override addEventListener(
    name: 'callEventSync',
    handler: (ev: CallEventSyncEvent) => void
  ): void;

  public override addEventListener(
    name: 'callLinkUpdateSync',
    handler: (ev: CallLinkUpdateSyncEvent) => void
  ): void;

  public override addEventListener(
    name: 'callLogEventSync',
    handler: (ev: CallLogEventSyncEvent) => void
  ): void;

  public override addEventListener(
    name: 'deleteForMeSync',
    handler: (ev: DeleteForMeSyncEvent) => void
  ): void;

  public override addEventListener(
    name: 'attachmentBackfillResponseSync',
    handler: (ev: AttachmentBackfillResponseSyncEvent) => void
  ): void;

  public override addEventListener(
    name: 'deviceNameChangeSync',
    handler: (ev: DeviceNameChangeSyncEvent) => void
  ): void;

  public override addEventListener(name: string, handler: EventHandler): void {
    return super.addEventListener(name, handler);
  }

  public override removeEventListener(
    name: string,
    handler: EventHandler
  ): void {
    return super.removeEventListener(name, handler);
  }

  //
  // Private
  //

  async #dispatchAndWait(id: string, event: Event): Promise<void> {
    drop(
      this.#appQueue.add(
        createTaskWithTimeout(
          async () => Promise.all(this.dispatchEvent(event)),
          `dispatchEvent(${event.type}, ${id})`,
          TASK_WITH_TIMEOUT_OPTIONS
        )
      )
    );
  }

  #calculateMessageAge(
    timestamp: number | undefined,
    serverTimestamp: number | undefined
  ): number {
    // Default to 0 in case of unreliable parameters.
    // One final sanity check, the timestamp when a message is pulled from
    // the server should be later than when it was pushed.
    return serverTimestamp && timestamp && timestamp > serverTimestamp
      ? Math.floor((timestamp - serverTimestamp) / 1000)
      : 0;
  }

  async #addToQueue<T>(
    task: () => Promise<T>,
    id: string,
    taskType: TaskType
  ): Promise<T> {
    const queue =
      taskType === TaskType.Encrypted
        ? this.#encryptedQueue
        : this.#decryptedQueue;

    return queue.add(
      createTaskWithTimeout(task, id, TASK_WITH_TIMEOUT_OPTIONS)
    );
  }

  #onEmpty(): void {
    const emitEmpty = async () => {
      await Promise.all([
        this.#decryptAndCacheBatcher.flushAndWait(),
        this.#cacheRemoveBatcher.flushAndWait(),
      ]);

      if (this.#pniIdentityKeyCheckRequired) {
        log.warn("got 'empty' event, running scheduled pni identity key check");
        drop(checkOurPniIdentityKey());
      }
      this.#pniIdentityKeyCheckRequired = false;

      log.info("emitting 'empty' event");
      this.dispatchEvent(new EmptyEvent());
      this.#isEmptied = true;

      this.#maybeScheduleRetryTimeout();
    };

    const waitForDecryptedQueue = async () => {
      log.info(
        "finished processing messages after 'empty', now waiting for application"
      );

      // We don't await here because we don't want this to gate future message processing
      drop(
        this.#appQueue.add(
          createTaskWithTimeout(
            emitEmpty,
            'emitEmpty',
            TASK_WITH_TIMEOUT_OPTIONS
          )
        )
      );
    };

    const waitForEncryptedQueue = async () => {
      drop(
        this.#addToQueue(
          waitForDecryptedQueue,
          'onEmpty/waitForDecrypted',
          TaskType.Decrypted
        )
      );
    };

    const waitForIncomingQueue = async () => {
      drop(
        this.#addToQueue(
          waitForEncryptedQueue,
          'onEmpty/waitForEncrypted',
          TaskType.Encrypted
        )
      );
    };

    const waitForCacheAddBatcher = async () => {
      await this.#decryptAndCacheBatcher.onIdle();
      drop(
        this.#incomingQueue.add(
          createTaskWithTimeout(
            waitForIncomingQueue,
            'onEmpty/waitForIncoming',
            TASK_WITH_TIMEOUT_OPTIONS
          )
        )
      );
    };

    drop(waitForCacheAddBatcher());
  }

  async #queueAllCached(): Promise<void> {
    if (this.#stoppingProcessing) {
      log.info('queueAllCached: not running due to stopped processing');
      return;
    }

    for await (const batch of this.#getAllFromCache()) {
      for (const item of batch) {
        // eslint-disable-next-line no-await-in-loop
        await this.#queueCached(item);
      }
    }
    log.info('queueAllCached - finished');
  }

  async #queueCached(item: UnprocessedType): Promise<void> {
    log.info('queueCached', item.id);
    try {
      const ourAci = this.#storage.user.getCheckedAci();

      const envelope: ProcessedEnvelope = {
        id: item.id,
        receivedAtCounter: item.receivedAtCounter,
        receivedAtDate: item.receivedAtDate,
        messageAgeSec: item.messageAgeSec,

        // Proto.Envelope fields
        type: item.type,
        source: item.source,
        sourceServiceId: normalizeServiceId(
          item.sourceServiceId,
          'CachedEnvelope.sourceServiceId'
        ),
        sourceDevice: item.sourceDevice,
        destinationServiceId: normalizeServiceId(
          item.destinationServiceId || ourAci,
          'CachedEnvelope.destinationServiceId'
        ),
        updatedPni: isUntaggedPniString(item.updatedPni)
          ? normalizePni(
              toTaggedPni(item.updatedPni),
              'CachedEnvelope.updatedPni'
            )
          : undefined,
        timestamp: item.timestamp,
        content: item.content,
        serverGuid: item.serverGuid,
        serverTimestamp: item.serverTimestamp,
        urgent: isBoolean(item.urgent) ? item.urgent : true,
        story: Boolean(item.story),
        reportingToken: item.reportingToken,
        groupId: item.groupId,
      };

      if (!item.isEncrypted) {
        strictAssert(
          envelope.sourceServiceId,
          'Decrypted envelope must have source uuid'
        );

        // Pacify typescript
        const decryptedEnvelope = {
          ...envelope,
          sourceServiceId: envelope.sourceServiceId,
        };

        // Maintain invariant: encrypted queue => decrypted queue
        const envelopeId = getEnvelopeId(decryptedEnvelope);
        const taskId = `queueCached(EnvelopeEvent(${envelopeId}))`;
        drop(
          this.#addToQueue(
            async () =>
              this.#dispatchAndWait(
                taskId,
                new EnvelopeQueuedEvent(decryptedEnvelope)
              ),
            taskId,
            TaskType.Decrypted
          )
        );
        drop(
          this.#addToQueue(
            async () => {
              void this.#queueDecryptedEnvelope(
                decryptedEnvelope,
                item.content
              );
            },
            `queueDecryptedEnvelope(${getEnvelopeId(decryptedEnvelope)})`,
            TaskType.Encrypted
          )
        );
      } else {
        void this.#queueCachedEnvelope(item, envelope);
      }
    } catch (error) {
      log.error(
        'queueCached error handling item',
        item.id,
        'removing it. Error:',
        Errors.toLogFormat(error)
      );

      try {
        const { id } = item;
        await signalProtocolStore.removeUnprocessed(id);
      } catch (deleteError) {
        log.error(
          'queueCached error deleting item',
          item.id,
          'Error:',
          Errors.toLogFormat(deleteError)
        );
      }
    }
  }

  #clearRetryTimeout(): void {
    clearTimeoutIfNecessary(this.#retryCachedTimeout);
    this.#retryCachedTimeout = undefined;
  }

  #maybeScheduleRetryTimeout(): void {
    if (this.#isEmptied) {
      this.#clearRetryTimeout();
      this.#retryCachedTimeout = setTimeout(() => {
        drop(
          this.#incomingQueue.add(
            createTaskWithTimeout(
              async () => this.#queueAllCached(),
              'queueAllCached',
              TASK_WITH_TIMEOUT_OPTIONS
            )
          )
        );
      }, RETRY_TIMEOUT);
    }
  }

  async *#getAllFromCache(): AsyncIterable<Array<UnprocessedType>> {
    log.info('getAllFromCache');

    const ids = await signalProtocolStore.getAllUnprocessedIds();

    log.info(`getAllFromCache - ${ids.length} unprocessed`);

    for (const batch of chunk(ids, 1000)) {
      log.info(`getAllFromCache - yielding batch of ${batch.length}`);
      yield signalProtocolStore.getUnprocessedByIdsAndIncrementAttempts(batch);
    }
    log.info(`getAllFromCache - done retrieving ${ids.length} unprocessed`);
  }

  async #decryptAndCacheBatch(items: Array<CacheAddItemType>): Promise<void> {
    log.info('decryptAndCacheBatch', items.length);

    const decrypted: Array<
      Readonly<{
        plaintext: Uint8Array<ArrayBuffer>;
        data: UnprocessedType;
        envelope: UnsealedEnvelope;
      }>
    > = [];

    const storageProtocol = signalProtocolStore;

    try {
      const zone = new Zone('decryptAndCacheBatch', {
        pendingKyberPreKeysToRemove: true,
        pendingPreKeysToRemove: true,
        pendingSenderKeys: true,
        pendingSessions: true,
        pendingUnprocessed: true,
      });

      const storesMap = new Map<ServiceIdString, LockedStores>();
      const failed: Array<UnprocessedType> = [];

      // Below we:
      //
      // 1. Enter zone
      // 2. Decrypt all batched envelopes
      // 3. Persist both decrypted envelopes and envelopes that we failed to
      //    decrypt (for future retries, see `attempts` field)
      // 4. Leave zone and commit all pending sessions and unprocesseds
      // 5. Acknowledge envelopes (can't fail)
      // 6. Finally process decrypted envelopes
      await storageProtocol.withZone(zone, 'MessageReceiver', async () => {
        await Promise.all<void>(
          items.map(async ({ data, envelope }) => {
            try {
              const { destinationServiceId } = envelope;

              let stores = storesMap.get(destinationServiceId);
              if (!stores) {
                const sharedParams = {
                  ourServiceId: destinationServiceId,
                  zone,
                };
                stores = {
                  identityKeyStore: new IdentityKeys(sharedParams),
                  kyberPreKeyStore: new KyberPreKeys(sharedParams),
                  preKeyStore: new PreKeys(sharedParams),
                  senderKeyStore: new SenderKeys(sharedParams),
                  sessionStore: new Sessions(sharedParams),
                  signedPreKeyStore: new SignedPreKeys(sharedParams),
                  zone,
                };
                storesMap.set(destinationServiceId, stores);
              }

              const result = await this.#queueEncryptedEnvelope(
                stores,
                envelope
              );
              if (result.plaintext) {
                decrypted.push({
                  plaintext: result.plaintext,
                  envelope: result.envelope,
                  data,
                });
              }
            } catch (error) {
              failed.push(data);
              log.error(
                'decryptAndCacheBatch error when processing the envelope',
                Errors.toLogFormat(error)
              );
            }
          })
        );

        log.info(
          'decryptAndCacheBatch storing ' +
            `${decrypted.length} decrypted envelopes, keeping ` +
            `${failed.length} failed envelopes.`
        );

        // Store both decrypted and failed unprocessed envelopes
        const unprocesseds: Array<UnprocessedType> = decrypted.map(
          ({ envelope, data, plaintext }) => {
            return {
              ...data,

              source: envelope.source,
              sourceServiceId: envelope.sourceServiceId,
              sourceDevice: envelope.sourceDevice,
              destinationServiceId: envelope.destinationServiceId,
              updatedPni: envelope.updatedPni,
              serverGuid: envelope.serverGuid,
              serverTimestamp: envelope.serverTimestamp,
              timestamp: envelope.timestamp,
              isEncrypted: false,
              content: plaintext,
            };
          }
        );

        await storageProtocol.addMultipleUnprocessed(
          unprocesseds.concat(failed),
          { zone }
        );
      });

      log.info('decryptAndCacheBatch acknowledging receipt');

      // Acknowledge all envelopes
      for (const { request } of items) {
        try {
          request.respond(200, 'OK');
        } catch (error) {
          log.error(
            'decryptAndCacheBatch: Failed to send 200 to server; still queuing envelope'
          );
        }
      }
    } catch (error) {
      log.error(
        'decryptAndCache error trying to add messages to cache:',
        Errors.toLogFormat(error)
      );

      items.forEach(item => {
        item.request.respond(500, 'Failed to cache message');
      });
      return;
    }

    // Force save of unprocessed envelopes for testing
    if (window.SignalCI?.forceUnprocessed) {
      return;
    }

    // Now, queue and process decrypted envelopes. We drop the promise so that the next
    // decryptAndCacheBatch batch does not have to wait for the decrypted envelopes to be
    // processed, which can be an asynchronous blocking operation
    drop(this.#queueAllDecryptedEnvelopes(decrypted));
  }

  // The final step in decryptAndCacheBatch: queue the decrypted envelopes for processing
  async #queueAllDecryptedEnvelopes(
    decrypted: Array<Required<DecryptResult>>
  ): Promise<void> {
    await Promise.all(
      decrypted.map(async ({ envelope, plaintext }) => {
        try {
          await this.#queueDecryptedEnvelope(envelope, plaintext);
        } catch (error) {
          log.error(
            'decryptAndCache error when processing decrypted envelope',
            Errors.toLogFormat(error)
          );
        }
      })
    );

    log.info('decryptAndCacheBatch fully processed');

    this.#maybeScheduleRetryTimeout();
  }

  #decryptAndCache(
    envelope: ProcessedEnvelope,
    request: IncomingWebSocketRequest
  ): void {
    const { id } = envelope;
    const data: UnprocessedType = {
      id,
      type: envelope.type,
      source: envelope.source,
      sourceServiceId: envelope.sourceServiceId,
      sourceDevice: envelope.sourceDevice,
      destinationServiceId: envelope.destinationServiceId,
      timestamp: envelope.timestamp,
      receivedAtDate: envelope.receivedAtDate,
      attempts: 0,
      isEncrypted: true,
      content: envelope.content,
      messageAgeSec: envelope.messageAgeSec,
      receivedAtCounter: envelope.receivedAtCounter,
      serverGuid: envelope.serverGuid,
      serverTimestamp: envelope.serverTimestamp,
      urgent: envelope.urgent,
      story: envelope.story,
      updatedPni: envelope.updatedPni,
      reportingToken: envelope.reportingToken,
      groupId: envelope.groupId,
    };
    this.#decryptAndCacheBatcher.add({
      request,
      envelope,
      data,
    });
  }

  async #cacheRemoveBatch(items: Array<string>): Promise<void> {
    await signalProtocolStore.removeUnprocessed(items);
  }

  #removeFromCache(envelope: ProcessedEnvelope): void {
    const { id } = envelope;
    this.#cacheRemoveBatcher.add(id);
  }

  async #queueDecryptedEnvelope(
    envelope: UnsealedEnvelope,
    plaintext: Uint8Array<ArrayBuffer>
  ): Promise<void> {
    const id = getEnvelopeId(envelope);
    log.info('queueing decrypted envelope', id);

    const task = this.#handleDecryptedEnvelope.bind(this, envelope, plaintext);
    const taskWithTimeout = createTaskWithTimeout(
      task,
      `queueDecryptedEnvelope ${id}`,
      TASK_WITH_TIMEOUT_OPTIONS
    );

    try {
      await this.#addToQueue(
        taskWithTimeout,
        `handleDecryptedEnvelope(${id})`,
        TaskType.Decrypted
      );
    } catch (error) {
      log.error(
        `queueDecryptedEnvelope error handling envelope ${id}:`,
        Errors.toLogFormat(error)
      );
    }
  }

  async #queueEncryptedEnvelope(
    stores: LockedStores,
    envelope: ProcessedEnvelope
  ): Promise<DecryptResult> {
    let logId = getEnvelopeId(envelope);
    log.info('queueing envelope', logId);

    const task = async (): Promise<DecryptResult> => {
      const { destinationServiceId } = envelope;
      const serviceIdKind = isPniString(destinationServiceId)
        ? ServiceIdKind.PNI
        : ServiceIdKind.ACI;

      const unsealedEnvelope = await this.#unsealEnvelope(
        stores,
        envelope,
        serviceIdKind
      );

      // Dropped early
      if (!unsealedEnvelope) {
        return { plaintext: undefined, envelope: undefined };
      }

      logId = getEnvelopeId(unsealedEnvelope);

      const taskId = `dispatchEvent(EnvelopeUnsealedEvent(${logId}))`;
      drop(
        this.#addToQueue(
          async () =>
            this.#dispatchAndWait(
              taskId,
              new EnvelopeUnsealedEvent(unsealedEnvelope)
            ),
          taskId,
          TaskType.Decrypted
        )
      );

      return this.#decryptEnvelope(stores, unsealedEnvelope, serviceIdKind);
    };

    try {
      return await this.#addToQueue(
        task,
        `MessageReceiver: unseal and decrypt ${logId}`,
        TaskType.Encrypted
      );
    } catch (error) {
      const args = [logId, ':', Errors.toLogFormat(error)];
      if (error instanceof WarnOnlyError) {
        log.warn('queueEncryptedEnvelope error handling envelope', ...args);
      } else {
        log.error('queueEncryptedEnvelope error handling envelope', ...args);
      }
      throw error;
    }
  }

  async #queueCachedEnvelope(
    data: UnprocessedType,
    envelope: ProcessedEnvelope
  ): Promise<void> {
    this.#decryptAndCacheBatcher.add({
      request: {
        respond(code, status) {
          log.info(
            'queueCachedEnvelope: fake response ' +
              `with code ${code} and status ${status}`
          );
        },
      },
      envelope,
      data,
    });
  }

  // Called after `decryptEnvelope` decrypted the message.
  async #handleDecryptedEnvelope(
    envelope: UnsealedEnvelope,
    plaintext: Uint8Array<ArrayBuffer>
  ): Promise<void> {
    if (this.#stoppingProcessing) {
      return;
    }

    if (!envelope.content) {
      this.#removeFromCache(envelope);
      throw new Error('Received message with no content');
    }

    await this.#innerHandleContentMessage(envelope, plaintext);
  }

  async #unsealEnvelope(
    stores: LockedStores,
    envelope: ProcessedEnvelope,
    serviceIdKind: ServiceIdKind
  ): Promise<UnsealedEnvelope | undefined> {
    const logId = getEnvelopeId(envelope);

    if (this.#stoppingProcessing) {
      log.warn(`unsealEnvelope(${logId}): dropping`);
      throw new Error('Sealed envelope dropped due to stopping processing');
    }

    if (envelope.type !== Proto.Envelope.Type.UNIDENTIFIED_SENDER) {
      strictAssert(
        envelope.sourceServiceId,
        'Unsealed envelope must have source uuid'
      );

      if (
        isPniString(envelope.sourceServiceId) &&
        envelope.type !== Proto.Envelope.Type.SERVER_DELIVERY_RECEIPT
      ) {
        log.warn(
          `unsealEnvelope(${logId}): dropping non-receipt envelope from PNI`
        );
        return undefined;
      }

      return {
        ...envelope,
        sourceServiceId: envelope.sourceServiceId,
        cipherTextBytes: envelope.content,
        cipherTextType: envelopeTypeToCiphertextType(envelope.type),
      };
    }

    if (serviceIdKind === ServiceIdKind.PNI) {
      log.warn(`unsealEnvelope(${logId}): dropping for PNI`);
      return undefined;
    }

    strictAssert(
      serviceIdKind === ServiceIdKind.ACI,
      'Sealed non-ACI envelope'
    );

    const ciphertext = envelope.content;
    if (!ciphertext) {
      this.#removeFromCache(envelope);
      throw new Error('Received message with no content');
    }

    log.info(`unsealEnvelope(${logId}): unidentified message`);
    const messageContent = await sealedSenderDecryptToUsmc(
      ciphertext,
      stores.identityKeyStore
    );

    // Here we take this sender information and attach it back to the envelope
    //   to make the rest of the app work properly.
    const certificate = messageContent.senderCertificate();

    const originalSource = envelope.source;
    const originalSourceUuid = envelope.sourceServiceId;

    const groupId = messageContent.groupId();

    const newEnvelope: UnsealedEnvelope = {
      ...envelope,

      cipherTextBytes: messageContent.contents(),
      cipherTextType: messageContent.msgType(),

      // Overwrite Envelope fields
      source: dropNull(certificate.senderE164()),
      sourceServiceId: normalizeServiceId(
        certificate.senderUuid(),
        'MessageReceiver.unsealEnvelope.UNIDENTIFIED_SENDER.sourceServiceId'
      ),
      sourceDevice: certificate.senderDeviceId(),

      // UnsealedEnvelope-only fields
      unidentifiedDeliveryReceived: !(originalSource || originalSourceUuid),
      contentHint: messageContent.contentHint(),
      groupId: groupId ? Bytes.toBase64(groupId) : undefined,
      certificate,
      unsealedContent: messageContent,
    };

    // This will throw if there's a problem
    this.#validateUnsealedEnvelope(newEnvelope);

    return newEnvelope;
  }

  async #decryptEnvelope(
    stores: LockedStores,
    envelope: UnsealedEnvelope,
    serviceIdKind: ServiceIdKind
  ): Promise<DecryptResult> {
    const logId = `decryptEnvelope(${getEnvelopeId(envelope)})`;

    if (this.#stoppingProcessing) {
      log.warn(`${logId}: dropping unsealed`);
      throw new Error('Unsealed envelope dropped due to stopping processing');
    }

    if (envelope.type === Proto.Envelope.Type.SERVER_DELIVERY_RECEIPT) {
      strictAssert(
        envelope.sourceServiceId,
        'Unsealed delivery receipt must have sourceServiceId'
      );
      await this.#onDeliveryReceipt(envelope);
      return { plaintext: undefined, envelope };
    }

    let ciphertext: Uint8Array<ArrayBuffer>;
    if (envelope.content) {
      ciphertext = envelope.content;
    } else {
      this.#removeFromCache(envelope);
      strictAssert(
        false,
        'Contentless envelope should be handled by unsealEnvelope'
      );
    }

    log.info(logId);
    const decryptResult = await this.#decrypt(
      stores,
      envelope,
      ciphertext,
      serviceIdKind
    );

    if (!decryptResult) {
      log.warn(`${logId}: plaintext was falsey`);
      return { plaintext: undefined, envelope };
    }

    const { plaintext, wasEncrypted } = decryptResult;

    // Note: we need to process this as part of decryption, because we might need this
    //   sender key to decrypt the next message in the queue!
    let isGroupV2 = false;

    let inProgressMessageType = '';
    try {
      const content = Proto.Content.decode(plaintext);
      if (
        !wasEncrypted &&
        Bytes.isEmpty(content.content?.decryptionErrorMessage)
      ) {
        log.warn(
          `${logId}: dropping plaintext envelope without decryption error message`
        );

        const { sourceServiceId: senderAci } = envelope;
        strictAssert(isAciString(senderAci), 'Sender uuid must be an ACI');

        const event = new InvalidPlaintextEvent({
          senderDevice: envelope.sourceDevice ?? 1,
          senderAci,
          timestamp: envelope.timestamp,
        });

        this.#removeFromCache(envelope);

        const envelopeId = getEnvelopeId(envelope);

        // Avoid deadlocks by scheduling processing on decrypted queue
        drop(
          this.#addToQueue(
            async () => this.dispatchEvent(event),
            `decrypted/dispatchEvent/InvalidPlaintextEvent(${envelopeId})`,
            TaskType.Decrypted
          )
        );

        return { plaintext: undefined, envelope };
      }

      isGroupV2 =
        Boolean(content.content?.dataMessage?.groupV2) ||
        Boolean(content.content?.storyMessage?.group);

      if (
        wasEncrypted &&
        content.senderKeyDistributionMessage &&
        Bytes.isNotEmpty(content.senderKeyDistributionMessage)
      ) {
        inProgressMessageType = 'sender key distribution';
        await this.#handleSenderKeyDistributionMessage(
          stores,
          envelope,
          content.senderKeyDistributionMessage
        );
      }

      const isStoryReply = Boolean(content.content?.dataMessage?.storyContext);
      const isGroupStoryReply = Boolean(
        isStoryReply && content.content?.dataMessage?.groupV2
      );
      const isStory = Boolean(content.content?.storyMessage);
      const isDeleteForEveryone = Boolean(content.content?.dataMessage?.delete);

      if (
        envelope.story &&
        !(isGroupStoryReply || isStory) &&
        !isDeleteForEveryone
      ) {
        log.warn(
          `${logId}: Dropping story message - story=true on envelope, but message was not a story send or delete`
        );
        this.#removeFromCache(envelope);
        return { plaintext: undefined, envelope };
      }

      if (!envelope.story && (isGroupStoryReply || isStory)) {
        log.warn(
          `${logId}: Malformed story - story=false on envelope, but was a story send`
        );
      }

      const areStoriesBlocked = getStoriesBlocked();
      // Note that there are other story-related message types which aren't captured
      //   here. Look for other calls to getStoriesBlocked down-file.
      if (areStoriesBlocked && (isStoryReply || isStory)) {
        log.warn(
          `${logId}: Dropping story message - stories are disabled or unavailable`
        );
        this.#removeFromCache(envelope);
        return { plaintext: undefined, envelope };
      }

      const sender = window.ConversationController.get(
        envelope.sourceServiceId || envelope.source
      );
      if (
        (isStoryReply || isStory) &&
        !isGroupV2 &&
        (!sender || !sender.get('profileSharing'))
      ) {
        log.warn(
          `${logId}: Dropping story message - !profileSharing for sender`
        );
        this.#removeFromCache(envelope);
        return { plaintext: undefined, envelope };
      }

      if (wasEncrypted && content.pniSignatureMessage) {
        inProgressMessageType = 'pni signature';
        await this.#handlePniSignatureMessage(
          envelope,
          content.pniSignatureMessage
        );
      }

      // Some sync messages have to be fully processed in the middle of
      // decryption queue since subsequent envelopes use their key material.
      const syncMessage = content.content?.syncMessage;
      if (wasEncrypted && syncMessage?.content?.pniChangeNumber) {
        inProgressMessageType = 'pni change number';
        await this.#handlePNIChangeNumber(
          envelope,
          syncMessage.content.pniChangeNumber
        );
        this.#removeFromCache(envelope);
        return { plaintext: undefined, envelope };
      }

      inProgressMessageType = '';
    } catch (error) {
      log.error(
        `${logId}: Failed to process ${inProgressMessageType} ` +
          `message: ${Errors.toLogFormat(error)}`
      );
    }

    if (
      (envelope.source && this.#isBlocked(envelope.source)) ||
      (envelope.sourceServiceId &&
        this.#isServiceIdBlocked(envelope.sourceServiceId))
    ) {
      log.info(`${logId}: Dropping message from blocked sender`);
      this.#removeFromCache(envelope);
      return { plaintext: undefined, envelope };
    }

    return { plaintext, envelope };
  }

  #validateUnsealedEnvelope(envelope: UnsealedEnvelope): void {
    const {
      unsealedContent: messageContent,
      certificate,
      serverTimestamp,
    } = envelope;

    const envelopeId = getEnvelopeId(envelope);
    const logId = `validateUnsealedEnvelope(${envelopeId})`;

    strictAssert(
      messageContent !== undefined,
      `${logId}: Missing message content for sealed sender message`
    );
    strictAssert(
      certificate !== undefined,
      `${logId}: Missing sender certificate for sealed sender message`
    );
    strictAssert(
      serverTimestamp > 0,
      `${logId}: Sealed sender message was missing serverTimestamp`
    );

    const localAci = this.#storage.user.getCheckedAci();
    const localDeviceId = parseIntOrThrow(
      this.#storage.user.getDeviceId(),
      'MessageReceiver.decryptSealedSender: localDeviceId'
    );

    if (
      certificate.senderAci()?.getServiceIdString() === localAci &&
      certificate.senderDeviceId() === localDeviceId
    ) {
      throw new Error(
        `${logId}: Received sealed sender message sent by this device`
      );
    }

    if (
      !certificate.validateWithTrustRoots(
        this.#serverTrustRoots,
        serverTimestamp
      )
    ) {
      throw new Error(`${logId}: Sealed sender certificate validation failed`);
    }

    return undefined;
  }

  async #onDeliveryReceipt(envelope: ProcessedEnvelope): Promise<void> {
    logUnexpectedUrgentValue(envelope, 'deliveryReceipt');

    await this.#dispatchAndWait(
      getEnvelopeId(envelope),
      new DeliveryEvent(
        [
          {
            timestamp: envelope.timestamp,
            source: envelope.source,
            sourceServiceId: envelope.sourceServiceId,
            sourceDevice: envelope.sourceDevice,
            wasSentEncrypted: false,
          },
        ],
        envelope.id,
        envelope.timestamp,
        this.#removeFromCache.bind(this, envelope)
      )
    );
  }

  #unpad(paddedPlaintext: Uint8Array<ArrayBuffer>): Uint8Array<ArrayBuffer> {
    for (let i = paddedPlaintext.length - 1; i >= 0; i -= 1) {
      if (paddedPlaintext[i] === 0x80) {
        return new Uint8Array(paddedPlaintext.subarray(0, i));
      }
      if (paddedPlaintext[i] !== 0x00) {
        throw new Error('Invalid padding');
      }
    }

    return paddedPlaintext;
  }

  async #decryptSealedSender(
    {
      identityKeyStore,
      kyberPreKeyStore,
      preKeyStore,
      senderKeyStore,
      sessionStore,
      signedPreKeyStore,
      zone,
    }: LockedStores,
    envelope: UnsealedEnvelope
  ): Promise<DecryptSealedSenderResult> {
    const { destinationServiceId } = envelope;
    const logId = getEnvelopeId(envelope);

    const { unsealedContent: messageContent, certificate } = envelope;
    strictAssert(
      messageContent !== undefined,
      'Missing message content for sealed sender message'
    );
    strictAssert(
      certificate !== undefined,
      'Missing sender certificate for sealed sender message'
    );

    if (messageContent.msgType() === CiphertextMessageType.Plaintext) {
      log.info(
        `decryptSealedSender(${logId}): ` +
          'unidentified message/plaintext contents'
      );
      const plaintextContent = PlaintextContent.deserialize(
        messageContent.contents()
      );

      return {
        plaintext: plaintextContent.body(),
        wasEncrypted: false,
      };
    }

    if (messageContent.msgType() === CiphertextMessageType.SenderKey) {
      log.info(
        `decryptSealedSender(${logId}): ` +
          'unidentified message/sender key contents'
      );
      const sealedSenderIdentifier = certificate.senderUuid();
      const sealedSenderSourceDevice = certificate.senderDeviceId();

      strictAssert(
        isServiceIdString(sealedSenderIdentifier),
        'Sealed sender identifier is service id'
      );
      const address = new QualifiedAddress(
        destinationServiceId,
        Address.create(sealedSenderIdentifier, sealedSenderSourceDevice)
      );

      const plaintext = await signalProtocolStore.enqueueSenderKeyJob(
        address,
        () =>
          groupDecrypt(
            ProtocolAddress.new(
              sealedSenderIdentifier,
              sealedSenderSourceDevice
            ),
            senderKeyStore,
            messageContent.contents()
          ),
        zone
      );
      return { plaintext, wasEncrypted: true };
    }

    log.info(
      `decryptSealedSender(${logId}): ` +
        'unidentified message/passing to sealedSenderDecryptMessage'
    );

    const sealedSenderIdentifier = envelope.sourceServiceId;
    strictAssert(
      sealedSenderIdentifier !== undefined,
      'Empty sealed sender identifier'
    );
    strictAssert(
      envelope.sourceDevice !== undefined,
      'Empty sealed sender device'
    );
    const address = new QualifiedAddress(
      destinationServiceId,
      Address.create(sealedSenderIdentifier, envelope.sourceDevice)
    );
    const protocolAddress = ProtocolAddress.new(
      sealedSenderIdentifier,
      envelope.sourceDevice
    );
    const message =
      messageContent.msgType() === CiphertextMessageType.PreKey
        ? PreKeySignalMessage.deserialize(messageContent.contents())
        : SignalMessage.deserialize(messageContent.contents());
    const plaintext = await signalProtocolStore.enqueueSessionJob(
      address,
      () => {
        if (message instanceof PreKeySignalMessage) {
          return signalDecryptPreKey(
            message,
            protocolAddress,
            sessionStore,
            identityKeyStore,
            preKeyStore,
            signedPreKeyStore,
            kyberPreKeyStore
          );
        }
        return signalDecrypt(
          message,
          protocolAddress,
          sessionStore,
          identityKeyStore
        );
      },
      zone
    );

    return { plaintext, wasEncrypted: true };
  }

  async #innerDecrypt(
    stores: LockedStores,
    envelope: UnsealedEnvelope,
    ciphertext: Uint8Array<ArrayBuffer>,
    serviceIdKind: ServiceIdKind
  ): Promise<InnerDecryptResultType | undefined> {
    const {
      identityKeyStore,
      kyberPreKeyStore,
      preKeyStore,
      sessionStore,
      signedPreKeyStore,
      zone,
    } = stores;

    const logId = getEnvelopeId(envelope);
    const envelopeTypeEnum = Proto.Envelope.Type;

    const identifier = envelope.sourceServiceId;
    const { sourceDevice } = envelope;

    const { destinationServiceId } = envelope;

    strictAssert(identifier !== undefined, 'Empty identifier');
    strictAssert(sourceDevice !== undefined, 'Empty source device');

    const address = new QualifiedAddress(
      destinationServiceId,
      Address.create(identifier, sourceDevice)
    );

    if (
      serviceIdKind === ServiceIdKind.PNI &&
      envelope.type !== envelopeTypeEnum.PREKEY_MESSAGE
    ) {
      log.warn(`innerDecrypt(${logId}): non-PreKey envelope on PNI`);
      return undefined;
    }

    strictAssert(
      serviceIdKind === ServiceIdKind.PNI ||
        serviceIdKind === ServiceIdKind.ACI,
      `Unsupported serviceIdKind: ${serviceIdKind}`
    );

    if (envelope.type === envelopeTypeEnum.PLAINTEXT_CONTENT) {
      log.info(`decrypt/${logId}: plaintext message`);
      const plaintextContent = PlaintextContent.deserialize(ciphertext);

      return {
        plaintext: this.#unpad(plaintextContent.body()),
        wasEncrypted: false,
      };
    }
    if (envelope.type === envelopeTypeEnum.DOUBLE_RATCHET) {
      log.info(`decrypt/${logId}: ciphertext message`);
      if (!identifier) {
        throw new Error(
          'MessageReceiver.innerDecrypt: No identifier for CIPHERTEXT message'
        );
      }
      if (!sourceDevice) {
        throw new Error(
          'MessageReceiver.innerDecrypt: No sourceDevice for CIPHERTEXT message'
        );
      }
      const signalMessage = SignalMessage.deserialize(ciphertext);

      const plaintext = await signalProtocolStore.enqueueSessionJob(
        address,
        async () =>
          this.#unpad(
            await signalDecrypt(
              signalMessage,
              ProtocolAddress.new(identifier, sourceDevice),
              sessionStore,
              identityKeyStore
            )
          ),
        zone
      );
      return { plaintext, wasEncrypted: true };
    }
    if (envelope.type === envelopeTypeEnum.PREKEY_MESSAGE) {
      log.info(`decrypt/${logId}: prekey message`);
      if (!identifier) {
        throw new Error(
          'MessageReceiver.innerDecrypt: No identifier for PREKEY_BUNDLE message'
        );
      }
      if (!sourceDevice) {
        throw new Error(
          'MessageReceiver.innerDecrypt: No sourceDevice for PREKEY_BUNDLE message'
        );
      }
      const preKeySignalMessage = PreKeySignalMessage.deserialize(ciphertext);

      const plaintext = await signalProtocolStore.enqueueSessionJob(
        address,
        async () =>
          this.#unpad(
            await signalDecryptPreKey(
              preKeySignalMessage,
              ProtocolAddress.new(identifier, sourceDevice),
              sessionStore,
              identityKeyStore,
              preKeyStore,
              signedPreKeyStore,
              kyberPreKeyStore
            )
          ),
        zone
      );
      return { plaintext, wasEncrypted: true };
    }
    if (envelope.type === envelopeTypeEnum.UNIDENTIFIED_SENDER) {
      log.info(`decrypt/${logId}: unidentified message`);
      const { plaintext, wasEncrypted } = await this.#decryptSealedSender(
        stores,
        envelope
      );
      return { plaintext: this.#unpad(plaintext), wasEncrypted };
    }
    throw new Error('Unknown message type');
  }

  async #decrypt(
    stores: LockedStores,
    envelope: UnsealedEnvelope,
    ciphertext: Uint8Array<ArrayBuffer>,
    serviceIdKind: ServiceIdKind
  ): Promise<InnerDecryptResultType | undefined> {
    const uuid = envelope.sourceServiceId;
    const deviceId = envelope.sourceDevice;
    const envelopeId = getEnvelopeId(envelope);

    try {
      const result = await this.#innerDecrypt(
        stores,
        envelope,
        ciphertext,
        serviceIdKind
      );

      if (isAciString(uuid) && isNumber(deviceId)) {
        const event = new SuccessfulDecryptEvent({
          senderDevice: deviceId,
          senderAci: uuid,
          timestamp: envelope.timestamp,
        });
        drop(
          this.#addToQueue(
            async () => this.dispatchEvent(event),
            `decrypted/dispatchEvent/SuccessfulDecryptEvent(${envelopeId})`,
            TaskType.Decrypted
          )
        );
      }

      return result;
    } catch (error) {
      const ourAci = this.#storage.user.getCheckedAci();
      const isFromMe = ourAci === uuid;

      // Job timed out, not a decryption error
      if (
        error?.name === 'TimeoutError' ||
        error?.message?.includes?.('task did not complete in time')
      ) {
        this.#removeFromCache(envelope);
        throw error;
      }

      // We don't do anything if it's just a duplicated message
      if (LibSignalErrorBase.is(error, LibSignalErrorCode.DuplicatedMessage)) {
        this.#removeFromCache(envelope);
        throw error;
      }

      if (
        (envelope.source && this.#isBlocked(envelope.source)) ||
        (envelope.sourceServiceId &&
          this.#isServiceIdBlocked(envelope.sourceServiceId))
      ) {
        log.info('decrypt: Error from blocked sender; no further processing');
        this.#removeFromCache(envelope);
        throw error;
      }

      if (uuid && deviceId) {
        const senderAci = uuid;
        if (!isAciString(senderAci)) {
          log.info('decrypt: Error from PNI; no further processing');
          this.#removeFromCache(envelope);
          throw error;
        }

        if (serviceIdKind === ServiceIdKind.PNI) {
          log.info(
            'decrypt: Error on PNI; no further processing; ' +
              'queueing pni identity check'
          );
          this.#pniIdentityKeyCheckRequired = true;
          this.#removeFromCache(envelope);
          throw error;
        }

        const { cipherTextBytes, cipherTextType } = envelope;
        const event = new DecryptionErrorEvent(
          {
            cipherTextBytes,
            cipherTextType,
            contentHint:
              envelope.contentHint ??
              (isFromMe ? ContentHint.Resendable : undefined),
            groupId: envelope.groupId,
            receivedAtCounter: envelope.receivedAtCounter,
            receivedAtDate: envelope.receivedAtDate,
            senderDevice: deviceId,
            senderAci,
            timestamp: envelope.timestamp,
          },
          () => this.#removeFromCache(envelope)
        );

        // Avoid deadlocks by scheduling processing on decrypted queue
        drop(
          this.#addToQueue(
            async () => this.dispatchEvent(event),
            `decrypted/dispatchEvent/DecryptionErrorEvent(${envelopeId})`,
            TaskType.Decrypted
          )
        );
      } else {
        this.#removeFromCache(envelope);
        log.error(`decrypt: Envelope ${envelopeId} missing uuid or deviceId`);
      }

      throw error;
    }
  }

  async #handleSentMessage(
    envelope: ProcessedEnvelope,
    sentContainer: ProcessedSent
  ) {
    const logId = `handleSentMessage/${getEnvelopeId(envelope)}`;
    log.info(logId);

    logUnexpectedUrgentValue(envelope, 'sentSync');

    const {
      destinationE164,
      destinationServiceId,
      timestamp,
      message: msg,
      expirationStartTimestamp,
      unidentifiedStatus,
      isRecipientUpdate,
    } = sentContainer;

    if (!msg) {
      throw new Error(`${logId}: message was falsey!`);
    }

    // TODO: DESKTOP-5804
    if (msg.flags && msg.flags & Proto.DataMessage.Flags.END_SESSION) {
      if (destinationServiceId) {
        await this.#handleEndSession(envelope, destinationServiceId);
      } else {
        throw new Error(`${logId}: Cannot end session with falsey destination`);
      }
    }

    const message = this.#processDecrypted(envelope, msg);

    if (message.body && isBodyTooLong(message.body)) {
      const length = Buffer.byteLength(message.body);
      this.#removeFromCache(envelope);
      log.warn(`${logId}: Dropping too-long message. Length: ${length}`);
    }

    strictAssert(timestamp, 'Missing sent timestamp');

    const ev = new SentEvent(
      {
        envelopeId: envelope.id,
        destinationE164: destinationE164 ?? '',
        destinationServiceId,
        timestamp: toNumber(timestamp),
        serverTimestamp: envelope.serverTimestamp,
        device: envelope.sourceDevice,
        unidentifiedStatus,
        message,
        isRecipientUpdate: Boolean(isRecipientUpdate),
        receivedAtCounter: envelope.receivedAtCounter,
        receivedAtDate: envelope.receivedAtDate,
        expirationStartTimestamp: toNumber(expirationStartTimestamp) ?? 0,
      },
      this.#removeFromCache.bind(this, envelope)
    );
    return this.#dispatchAndWait(getEnvelopeId(envelope), ev);
  }

  async #handleStoryMessage(
    envelope: UnsealedEnvelope,
    msg: Proto.StoryMessage,
    sentMessage?: ProcessedSent
  ): Promise<void> {
    const envelopeId = getEnvelopeId(envelope);
    const logId = `handleStoryMessage(${envelopeId})`;

    logUnexpectedUrgentValue(envelope, 'story');

    if (getStoriesBlocked()) {
      log.info(`${logId}: dropping`);
      this.#removeFromCache(envelope);
      return;
    }

    log.info(`${logId} starting`);

    const { sourceServiceId: sourceAci } = envelope;
    strictAssert(
      isAciString(sourceAci),
      'handleStoryMessage: received message from PNI'
    );

    const attachments: Array<ProcessedAttachment> = [];
    let preview: ReadonlyArray<ProcessedPreview> | undefined;

    if (msg.attachment?.fileAttachment != null) {
      const attachment = processAttachment(msg.attachment.fileAttachment);
      attachments.push(attachment);
    }

    if (msg.attachment?.textAttachment != null) {
      // If a text attachment has a link preview we remove it from the
      // textAttachment data structure and instead process the preview and add
      // it as a "preview" property for the message attributes.
      const {
        preview: unprocessedPreview,
        background,
        ...textAttachment
      } = msg.attachment.textAttachment;
      if (unprocessedPreview) {
        preview = processPreview([unprocessedPreview]);
      } else if (!textAttachment.text) {
        throw new Error('Text attachments must have text or link preview!');
      }

      attachments.push({
        size: textAttachment.text?.length ?? 0,
        contentType: TEXT_ATTACHMENT,
        textAttachment: {
          ...textAttachment,
          textStyle: isKnownProtoEnumMember(
            Proto.TextAttachment.Style,
            textAttachment.textStyle
          )
            ? textAttachment.textStyle
            : 0,
          gradient: background?.gradient,
          color: background?.color,
        } satisfies TextAttachmentType,
        blurHash: generateBlurHash(
          background?.color ?? background?.gradient?.startColor ?? undefined
        ),
      });
    }

    const groupV2 = msg.group ? processGroupV2Context(msg.group) : undefined;
    if (groupV2 && this.#isGroupBlocked(groupV2.id)) {
      log.warn(`${logId}: ignored; destined for blocked group`);
      this.#removeFromCache(envelope);
      return;
    }

    const timeRemaining = Math.min(
      Math.floor(envelope.timestamp + durations.DAY - Date.now()),
      durations.DAY
    );

    if (timeRemaining <= 0) {
      log.info(`${logId}: story already expired`);
      this.#removeFromCache(envelope);
      return;
    }

    const message: ProcessedDataMessage = {
      attachments,

      bodyRanges: filterAndClean(
        msg.bodyRanges.map(processBodyRange).filter(isNotNil)
      ),
      preview,
      canReplyToStory: Boolean(msg.allowsReplies),
      expireTimer: DurationInSeconds.DAY,
      expireTimerVersion: 0,
      flags: 0,
      groupV2,
      isStory: true,
      isViewOnce: false,
      timestamp: envelope.timestamp,
    };

    if (sentMessage && message.groupV2) {
      log.info(`${logId}: envelope is a sent group story`);
      const ev = new SentEvent(
        {
          envelopeId: envelope.id,
          destinationServiceId: envelope.destinationServiceId,
          device: envelope.sourceDevice,
          isRecipientUpdate: Boolean(sentMessage.isRecipientUpdate),
          message,
          receivedAtCounter: envelope.receivedAtCounter,
          receivedAtDate: envelope.receivedAtDate,
          serverTimestamp: envelope.serverTimestamp,
          timestamp: envelope.timestamp,
          unidentifiedStatus: sentMessage.storyMessageRecipients
            ?.map(({ destinationServiceId, isAllowedToReply }) => {
              if (!destinationServiceId) {
                return;
              }

              return {
                destinationServiceId,
                isAllowedToReplyToStory: Boolean(isAllowedToReply),
                destinationPniIdentityKey: undefined,
                unidentified: false,
              };
            })
            .filter(isNotNil),
        },
        this.#removeFromCache.bind(this, envelope)
      );
      void this.#dispatchAndWait(logId, ev);
      return;
    }

    if (sentMessage) {
      log.info(`${logId}: envelope is a sent distribution list story`);
      const { storyMessageRecipients } = sentMessage;
      const recipients = storyMessageRecipients ?? [];

      const isAllowedToReply = new Map<ServiceIdString, boolean>();
      const distributionListToSentServiceId = new Map<
        string,
        Set<ServiceIdString>
      >();

      recipients.forEach(recipient => {
        const { destinationServiceId } = recipient;
        if (!destinationServiceId) {
          return;
        }

        if (recipient.distributionListIds) {
          recipient.distributionListIds.forEach(listId => {
            const sentServiceIds: Set<ServiceIdString> =
              distributionListToSentServiceId.get(listId) || new Set();
            sentServiceIds.add(destinationServiceId);
            distributionListToSentServiceId.set(listId, sentServiceIds);
          });
        } else {
          assertDev(
            false,
            `${logId}: missing distribution list id for: ${destinationServiceId}`
          );
        }

        isAllowedToReply.set(
          destinationServiceId,
          recipient.isAllowedToReply !== false
        );
      });

      distributionListToSentServiceId.forEach((sentToServiceIds, listId) => {
        const ev = new SentEvent(
          {
            envelopeId: envelope.id,
            destinationServiceId: envelope.destinationServiceId,
            timestamp: envelope.timestamp,
            serverTimestamp: envelope.serverTimestamp,
            device: envelope.sourceDevice,
            unidentifiedStatus: Array.from(sentToServiceIds).map(
              destinationServiceId => ({
                destinationServiceId,
                isAllowedToReplyToStory:
                  isAllowedToReply.has(destinationServiceId),
                unidentified: false,
              })
            ),
            message,
            isRecipientUpdate: Boolean(sentMessage.isRecipientUpdate),
            receivedAtCounter: envelope.receivedAtCounter,
            receivedAtDate: envelope.receivedAtDate,
            storyDistributionListId: normalizeStoryDistributionId(
              listId,
              'storyDistributionListId'
            ),
          },
          this.#removeFromCache.bind(this, envelope)
        );
        void this.#dispatchAndWait(logId, ev);
      });
      return;
    }

    log.info(`${logId}: envelope is a received story`);
    const ev = new MessageEvent(
      {
        envelopeId: envelope.id,
        source: envelope.source,
        sourceAci,
        sourceDevice: envelope.sourceDevice,
        destinationServiceId: envelope.destinationServiceId,
        timestamp: envelope.timestamp,
        serverGuid: envelope.serverGuid,
        serverTimestamp: envelope.serverTimestamp,
        unidentifiedDeliveryReceived: Boolean(
          envelope.unidentifiedDeliveryReceived
        ),
        message,
        receivedAtCounter: envelope.receivedAtCounter,
        receivedAtDate: envelope.receivedAtDate,
      },
      this.#removeFromCache.bind(this, envelope)
    );
    return this.#dispatchAndWait(logId, ev);
  }

  async #handleEditMessage(
    envelope: UnsealedEnvelope,
    msg: Proto.EditMessage
  ): Promise<void> {
    const logId = `handleEditMessage(${getEnvelopeId(envelope)})`;
    log.info(logId);

    if (!msg.targetSentTimestamp) {
      log.info(`${logId}: cannot edit message. No targetSentTimestamp`);
      this.#removeFromCache(envelope);
      return;
    }

    if (!msg.dataMessage) {
      log.info(`${logId}: cannot edit message. No dataMessage`);
      this.#removeFromCache(envelope);
      return;
    }

    const message = this.#processDecrypted(envelope, msg.dataMessage);
    const groupId = this.#getProcessedGroupId(message);
    const isBlocked = groupId ? this.#isGroupBlocked(groupId) : false;

    if (groupId && isBlocked) {
      log.warn(
        `Message ${getEnvelopeId(envelope)} ignored; destined for blocked group`
      );
      this.#removeFromCache(envelope);
      return;
    }

    const { sourceServiceId: sourceAci } = envelope;
    strictAssert(
      isAciString(sourceAci),
      'MessageReceiver.handleEditMesage: received message from PNI'
    );

    const ev = new MessageEvent(
      {
        envelopeId: envelope.id,
        source: envelope.source,
        sourceAci,
        sourceDevice: envelope.sourceDevice,
        destinationServiceId: envelope.destinationServiceId,
        timestamp: envelope.timestamp,
        serverGuid: envelope.serverGuid,
        serverTimestamp: envelope.serverTimestamp,
        unidentifiedDeliveryReceived: Boolean(
          envelope.unidentifiedDeliveryReceived
        ),
        message: {
          ...message,
          editedMessageTimestamp: toNumber(msg.targetSentTimestamp),
        },
        receivedAtCounter: envelope.receivedAtCounter,
        receivedAtDate: envelope.receivedAtDate,
      },
      this.#removeFromCache.bind(this, envelope)
    );
    return this.#dispatchAndWait(logId, ev);
  }

  async #handleDataMessage(
    envelope: UnsealedEnvelope,
    msg: Proto.DataMessage
  ): Promise<void> {
    const logId = `handleDataMessage/${getEnvelopeId(envelope)}`;
    log.info(logId);

    if (getStoriesBlocked() && msg.storyContext) {
      log.info(
        `${logId}: Dropping incoming dataMessage with storyContext field`
      );
      this.#removeFromCache(envelope);
      return;
    }

    let p: Promise<void> = Promise.resolve();
    const { sourceServiceId: sourceAci } = envelope;
    if (!sourceAci) {
      throw new Error(`${logId}: sourceAci was falsey`);
    }

    strictAssert(isAciString(sourceAci), `${logId}: received message from PNI`);

    if (this.#isInvalidGroupData(msg, envelope)) {
      this.#removeFromCache(envelope);
      return undefined;
    }

    if (msg.flags && msg.flags & Proto.DataMessage.Flags.END_SESSION) {
      p = this.#handleEndSession(envelope, sourceAci);
    }

    const { profileKey } = msg;
    const hasProfileKey = profileKey && profileKey.length > 0;
    const isProfileKeyUpdate =
      msg.flags && msg.flags & Proto.DataMessage.Flags.PROFILE_KEY_UPDATE;

    if (isProfileKeyUpdate) {
      strictAssert(hasProfileKey, 'PROFILE_KEY_UPDATE without profileKey');
      logUnexpectedUrgentValue(envelope, 'profileKeyUpdate');
    }

    if (hasProfileKey) {
      const ev = new ProfileKeyUpdateEvent(
        {
          source: envelope.source,
          sourceAci,
          profileKey: Bytes.toBase64(profileKey),
        },
        isProfileKeyUpdate ? 'profileKeyUpdate' : 'profileKeyHarvest',
        isProfileKeyUpdate ? this.#removeFromCache.bind(this, envelope) : noop
      );

      if (isProfileKeyUpdate) {
        return this.#dispatchAndWait(getEnvelopeId(envelope), ev);
      }

      drop(this.#dispatchAndWait(getEnvelopeId(envelope), ev));
    }
    await p;

    let type: SendTypesType = 'message';

    if (msg.storyContext || msg.body) {
      type = 'message';
    } else if (msg.reaction) {
      type = 'reaction';
    } else if (msg.delete) {
      type = 'deleteForEveryone';
    } else if (
      msg.flags &&
      msg.flags & Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE
    ) {
      type = 'expirationTimerUpdate';
    }
    // Note: other data messages without any of these attributes will fall into the
    //   'message' bucket - like stickers, gift badges, etc.

    logUnexpectedUrgentValue(envelope, type);

    const message = this.#processDecrypted(envelope, msg);
    const groupId = this.#getProcessedGroupId(message);
    const isBlocked = groupId ? this.#isGroupBlocked(groupId) : false;

    if (groupId && isBlocked) {
      log.warn(`${logId}: message ignored; destined for blocked group`);
      this.#removeFromCache(envelope);
      return undefined;
    }

    if (message.body && isBodyTooLong(message.body)) {
      const length = Buffer.byteLength(message.body);
      this.#removeFromCache(envelope);
      log.warn(`${logId}: Dropping too-long message. Length: ${length}`);
    }

    const ev = new MessageEvent(
      {
        envelopeId: envelope.id,
        source: envelope.source,
        sourceAci,
        sourceDevice: envelope.sourceDevice,
        destinationServiceId: envelope.destinationServiceId,
        timestamp: envelope.timestamp,
        serverGuid: envelope.serverGuid,
        serverTimestamp: envelope.serverTimestamp,
        unidentifiedDeliveryReceived: Boolean(
          envelope.unidentifiedDeliveryReceived
        ),
        message,
        receivedAtCounter: envelope.receivedAtCounter,
        receivedAtDate: envelope.receivedAtDate,
      },
      this.#removeFromCache.bind(this, envelope)
    );

    return this.#dispatchAndWait(logId, ev);
  }

  async #maybeUpdateTimestamp(
    envelope: UnsealedEnvelope
  ): Promise<UnsealedEnvelope> {
    const { timestamp } = envelope;
    const identifier = envelope.groupId || envelope.sourceServiceId;
    const conversation = window.ConversationController.get(identifier);

    try {
      if (!conversation) {
        const idForLogging = envelope.groupId
          ? `groupv2(${envelope.groupId})`
          : envelope.sourceServiceId;
        log.info(
          `maybeUpdateTimestamp/${timestamp}: No conversation found for identifier ${idForLogging}`
        );
        return envelope;
      }

      const logId = `${conversation.idForLogging()}/${timestamp}`;
      const item = await retryPlaceholders.findByMessageAndRemove(
        conversation.id,
        timestamp
      );
      if (item && item.wasOpened) {
        log.info(
          `maybeUpdateTimestamp/${logId}: found retry placeholder, but conversation was opened. No updates made.`
        );
      } else if (item) {
        log.info(
          `maybeUpdateTimestamp/${logId}: found retry placeholder. Updating receivedAtCounter/receivedAtDate`
        );

        return {
          ...envelope,
          receivedAtCounter: item.receivedAtCounter,
          receivedAtDate: item.receivedAt,
        };
      }
    } catch (error) {
      log.error(
        `maybeUpdateTimestamp/${timestamp}: Failed to process message: ${Errors.toLogFormat(
          error
        )}`
      );
    }

    return envelope;
  }

  async #innerHandleContentMessage(
    incomingEnvelope: UnsealedEnvelope,
    plaintext: Uint8Array<ArrayBuffer>
  ): Promise<void> {
    const content = Proto.Content.decode(plaintext);
    const envelope = await this.#maybeUpdateTimestamp(incomingEnvelope);

    if (
      content.content?.decryptionErrorMessage &&
      Bytes.isNotEmpty(content.content.decryptionErrorMessage)
    ) {
      this.#handleDecryptionError(
        envelope,
        content.content.decryptionErrorMessage
      );
      return;
    }
    if (content.content?.syncMessage) {
      await this.#handleSyncMessage(envelope, content.content.syncMessage);
      return;
    }
    if (content.content?.dataMessage) {
      await this.#handleDataMessage(envelope, content.content.dataMessage);
      return;
    }
    if (content.content?.nullMessage) {
      this.#handleNullMessage(envelope);
      return;
    }
    if (content.content?.callMessage) {
      await this.#handleCallingMessage(envelope, content.content.callMessage);
      return;
    }
    if (content.content?.receiptMessage) {
      await this.#handleReceiptMessage(
        envelope,
        content.content.receiptMessage
      );
      return;
    }
    if (content.content?.typingMessage) {
      this.#handleTypingMessage(envelope, content.content.typingMessage);
      return;
    }

    if (content.content?.storyMessage) {
      await this.#handleStoryMessage(envelope, content.content.storyMessage);
      return;
    }

    if (content.content?.editMessage) {
      await this.#handleEditMessage(envelope, content.content.editMessage);
      return;
    }

    this.#removeFromCache(envelope);

    if (Bytes.isEmpty(content.senderKeyDistributionMessage)) {
      throw new Error('Unsupported content message');
    }
  }

  #handleDecryptionError(
    envelope: UnsealedEnvelope,
    decryptionError: Uint8Array<ArrayBuffer>
  ): void {
    const logId = getEnvelopeId(envelope);
    log.info(`handleDecryptionError: ${logId}`);

    logUnexpectedUrgentValue(envelope, 'retryRequest');

    const request = DecryptionErrorMessage.deserialize(decryptionError);

    const { sourceServiceId: sourceAci, sourceDevice } = envelope;
    if (!sourceAci || !sourceDevice) {
      log.error(`handleDecryptionError/${logId}: Missing uuid or device!`);
      this.#removeFromCache(envelope);
      return;
    }

    strictAssert(isAciString(sourceAci), 'Source uuid must be ACI');

    const event = new RetryRequestEvent(
      {
        groupId: envelope.groupId,
        requesterDevice: sourceDevice,
        requesterAci: sourceAci,
        ratchetKey: request.ratchetKey(),
        senderDevice: request.deviceId(),
        sentAt: request.timestamp(),
      },
      () => this.#removeFromCache(envelope)
    );
    this.dispatchEvent(event);
  }

  async #handleSenderKeyDistributionMessage(
    stores: LockedStores,
    envelope: UnsealedEnvelope,
    distributionMessage: Uint8Array<ArrayBuffer>
  ): Promise<void> {
    const envelopeId = getEnvelopeId(envelope);
    log.info(`handleSenderKeyDistributionMessage/${envelopeId}`);

    logUnexpectedUrgentValue(envelope, 'senderKeyDistributionMessage');

    // Note: we don't call removeFromCache here because this message can be combined
    //   with a dataMessage, for example. That processing will dictate cache removal.

    const { sourceServiceId, sourceDevice } = envelope;
    if (!sourceServiceId) {
      throw new Error(
        `handleSenderKeyDistributionMessage: Missing sourceServiceId for envelope ${envelopeId}`
      );
    }
    if (!isNumber(sourceDevice)) {
      throw new Error(
        `handleSenderKeyDistributionMessage: Missing sourceDevice for envelope ${envelopeId}`
      );
    }

    const sender = ProtocolAddress.new(sourceServiceId, sourceDevice);
    const senderKeyDistributionMessage =
      SenderKeyDistributionMessage.deserialize(distributionMessage);
    const { destinationServiceId } = envelope;
    const address = new QualifiedAddress(
      destinationServiceId,
      Address.create(sourceServiceId, sourceDevice)
    );

    await signalProtocolStore.enqueueSenderKeyJob(
      address,
      () =>
        processSenderKeyDistributionMessage(
          sender,
          senderKeyDistributionMessage,
          stores.senderKeyStore
        ),
      stores.zone
    );
  }

  async #handlePniSignatureMessage(
    envelope: UnsealedEnvelope,
    pniSignatureMessage: Proto.PniSignatureMessage
  ): Promise<void> {
    const envelopeId = getEnvelopeId(envelope);
    const logId = `handlePniSignatureMessage/${envelopeId}`;
    log.info(logId);

    // Note: we don't call removeFromCache here because this message can be combined
    //   with a dataMessage, for example. That processing will dictate cache removal.

    const aci = envelope.sourceServiceId;

    const { pni: pniBytes, signature } = pniSignatureMessage;
    strictAssert(Bytes.isNotEmpty(pniBytes), `${logId}: missing PNI bytes`);
    const pni = fromPniObject(Pni.fromUuidBytes(pniBytes));
    strictAssert(pni, `${logId}: missing PNI`);
    strictAssert(Bytes.isNotEmpty(signature), `${logId}: empty signature`);
    strictAssert(isAciString(aci), `${logId}: invalid ACI`);
    strictAssert(isPniString(pni), `${logId}: invalid PNI`);

    const isValid = await signalProtocolStore.verifyAlternateIdentity({
      aci,
      pni,
      signature,
    });

    if (isValid) {
      log.info(`${logId}: merging pni=${pni} aci=${aci}`);
      const { mergePromises } =
        window.ConversationController.maybeMergeContacts({
          pni,
          aci,
          e164: window.ConversationController.get(pni)?.get('e164'),
          fromPniSignature: true,
          reason: logId,
        });

      if (mergePromises.length) {
        await Promise.all(mergePromises);
      }
    }
  }

  async #handleCallingMessage(
    envelope: UnsealedEnvelope,
    callingMessage: Proto.CallMessage
  ): Promise<void> {
    logUnexpectedUrgentValue(envelope, 'callingMessage');

    this.#removeFromCache(envelope);

    const logId = `handleCallingMessage(${getEnvelopeId(envelope)})`;

    if (
      (envelope.source && this.#isBlocked(envelope.source)) ||
      (envelope.sourceServiceId &&
        this.#isServiceIdBlocked(envelope.sourceServiceId))
    ) {
      log.info(`${logId}: Dropping calling message from blocked sender`);
      this.#removeFromCache(envelope);
      return;
    }

    log.info(`${logId}: Passing to ringrtc`);
    await calling.handleCallingMessage(envelope, callingMessage);
  }

  async #handleReceiptMessage(
    envelope: UnsealedEnvelope,
    receiptMessage: Proto.ReceiptMessage
  ): Promise<void> {
    strictAssert(receiptMessage.timestamp, 'Receipt message without timestamp');

    let EventClass: typeof DeliveryEvent | typeof ReadEvent | typeof ViewEvent;
    let type: SendTypesType;
    switch (receiptMessage.type) {
      case Proto.ReceiptMessage.Type.DELIVERY:
        EventClass = DeliveryEvent;
        type = 'deliveryReceipt';
        break;
      case Proto.ReceiptMessage.Type.READ:
        EventClass = ReadEvent;
        type = 'readReceipt';
        break;
      case Proto.ReceiptMessage.Type.VIEWED:
        EventClass = ViewEvent;
        type = 'viewedReceipt';
        break;
      default:
        // This can happen if we get a receipt type we don't know about yet, which
        //   is totally fine.
        return;
    }

    logUnexpectedUrgentValue(envelope, type);

    const logId = getEnvelopeId(envelope);

    const receipts = receiptMessage.timestamp.map(rawTimestamp => ({
      timestamp: toNumber(rawTimestamp),
      source: envelope.source,
      sourceServiceId: envelope.sourceServiceId,
      sourceDevice: envelope.sourceDevice,
      wasSentEncrypted: true as const,
    }));

    await this.#dispatchAndWait(
      logId,
      new EventClass(
        receipts,
        envelope.id,
        envelope.timestamp,
        this.#removeFromCache.bind(this, envelope)
      )
    );
  }

  #handleTypingMessage(
    envelope: UnsealedEnvelope,
    typingMessage: Proto.TypingMessage
  ): void {
    this.#removeFromCache(envelope);

    logUnexpectedUrgentValue(envelope, 'typing');

    if (envelope.timestamp && typingMessage.timestamp) {
      const envelopeTimestamp = envelope.timestamp;
      const typingTimestamp = toNumber(typingMessage.timestamp);

      if (typingTimestamp !== envelopeTimestamp) {
        log.warn(
          `Typing message envelope timestamp (${envelopeTimestamp}) did not match typing timestamp (${typingTimestamp})`
        );
        return;
      }
    }

    strictAssert(
      envelope.sourceDevice !== undefined,
      'TypingMessage requires sourceDevice in the envelope'
    );
    strictAssert(
      isAciString(envelope.sourceServiceId),
      'Sender of typing indicator must be an ACI'
    );

    const { groupId, timestamp, action } = typingMessage;

    let groupV2IdString: string | undefined;
    if (groupId?.byteLength) {
      if (groupId.byteLength === GROUPV2_ID_LENGTH) {
        groupV2IdString = Bytes.toBase64(groupId);
      } else {
        log.error('handleTypingMessage: Received invalid groupId value');
      }
    }

    this.dispatchEvent(
      new TypingEvent({
        sender: envelope.source,
        senderAci: envelope.sourceServiceId,
        senderDevice: envelope.sourceDevice,
        typing: {
          groupV2Id: groupV2IdString,
          typingMessage,
          timestamp: toNumber(timestamp) ?? Date.now(),
          started: action === Proto.TypingMessage.Action.STARTED,
          stopped: action === Proto.TypingMessage.Action.STOPPED,
        },
      })
    );
  }

  #handleNullMessage(envelope: UnsealedEnvelope): void {
    log.info('handleNullMessage', getEnvelopeId(envelope));

    logUnexpectedUrgentValue(envelope, 'nullMessage');

    this.#removeFromCache(envelope);
  }

  #isInvalidGroupData(
    message: Proto.DataMessage,
    envelope: ProcessedEnvelope
  ): boolean {
    const { groupV2 } = message;

    if (groupV2) {
      const { masterKey } = groupV2;
      strictAssert(masterKey, 'Group v2 data has no masterKey');
      const isInvalid = masterKey.byteLength !== MASTER_KEY_LENGTH;

      if (isInvalid) {
        log.info(
          'isInvalidGroupData: invalid GroupV2 message from',
          getEnvelopeId(envelope)
        );
      }
      return isInvalid;
    }

    return false;
  }

  #getProcessedGroupId(message: ProcessedDataMessage): string | undefined {
    if (message.groupV2) {
      return message.groupV2.id;
    }
    return undefined;
  }

  #getGroupId(message: Proto.DataMessage): string | undefined {
    if (message.groupV2) {
      strictAssert(message.groupV2.masterKey, 'Missing groupV2.masterKey');
      const { id } = deriveGroupFields(message.groupV2.masterKey);
      return Bytes.toBase64(id);
    }

    return undefined;
  }

  #getDestination(sentMessage: ProcessedSent) {
    if (sentMessage.message && sentMessage.message.groupV2) {
      return `groupv2(${this.#getGroupId(sentMessage.message)})`;
    }
    return sentMessage.destinationServiceId;
  }

  async #handleSyncMessage(
    envelope: UnsealedEnvelope,
    syncMessage: Proto.SyncMessage
  ): Promise<void> {
    const ourNumber = this.#storage.user.getNumber();
    const ourAci = this.#storage.user.getCheckedAci();

    const fromSelfSource = envelope.source && envelope.source === ourNumber;
    const fromSelfSourceUuid =
      envelope.sourceServiceId && envelope.sourceServiceId === ourAci;
    if (!fromSelfSource && !fromSelfSourceUuid) {
      throw new Error('Received sync message from another number');
    }

    const ourDeviceId = this.#storage.user.getDeviceId();
    // eslint-disable-next-line eqeqeq
    if (envelope.sourceDevice == ourDeviceId) {
      throw new Error('Received sync message from our own device');
    }
    if (syncMessage.content?.sent) {
      const sentMessage = processSent(syncMessage.content.sent);

      if (sentMessage.editMessage) {
        return this.#handleSentEditMessage(envelope, sentMessage);
      }

      if (
        sentMessage.storyMessageRecipients?.length &&
        sentMessage.isRecipientUpdate
      ) {
        if (getStoriesBlocked()) {
          log.info(
            'handleSyncMessage: dropping story recipients update',
            getEnvelopeId(envelope)
          );
          this.#removeFromCache(envelope);
          return;
        }

        log.info(
          'handleSyncMessage: handling story recipients update',
          getEnvelopeId(envelope)
        );
        const ev = new StoryRecipientUpdateEvent(
          {
            destinationServiceId: envelope.destinationServiceId,
            timestamp: envelope.timestamp,
            storyMessageRecipients: sentMessage.storyMessageRecipients,
          },
          this.#removeFromCache.bind(this, envelope)
        );
        const logId = getEnvelopeId(envelope);
        return this.#dispatchAndWait(logId, ev);
      }

      if (sentMessage.storyMessage) {
        return this.#handleStoryMessage(
          envelope,
          sentMessage.storyMessage,
          sentMessage
        );
      }

      if (!sentMessage || !sentMessage.message) {
        throw new Error(
          'MessageReceiver.handleSyncMessage: sync sent message was missing message'
        );
      }

      if (this.#isInvalidGroupData(sentMessage.message, envelope)) {
        this.#removeFromCache(envelope);
        return;
      }

      strictAssert(sentMessage.timestamp, 'sent message without timestamp');

      log.info(
        'sent message to',
        this.#getDestination(sentMessage),
        toNumber(sentMessage.timestamp),
        'from',
        getEnvelopeId(envelope)
      );

      return this.#handleSentMessage(envelope, sentMessage);
    }
    if (syncMessage.content?.contacts) {
      // Note: this method will download attachment and thus might block
      // message processing, but we would like to fully process contact sync
      // before moving on since it updates conversation state.
      return this.#handleContacts(envelope, syncMessage.content.contacts);
    }
    if (syncMessage.content?.blocked) {
      return this.#handleBlocked(envelope, syncMessage.content.blocked);
    }
    if (syncMessage.content?.request) {
      log.info('Got SyncMessage Request');
      this.#removeFromCache(envelope);
      return;
    }
    if (syncMessage.read && syncMessage.read.length) {
      return this.#handleRead(envelope, syncMessage.read);
    }
    if (syncMessage.content?.verified) {
      log.info('Got verified sync message, dropping');
      this.#removeFromCache(envelope);
      return;
    }
    if (syncMessage.content?.configuration) {
      return this.#handleConfiguration(
        envelope,
        syncMessage.content.configuration
      );
    }
    if (
      syncMessage.stickerPackOperation &&
      syncMessage.stickerPackOperation.length > 0
    ) {
      return this.#handleStickerPackOperation(
        envelope,
        syncMessage.stickerPackOperation
      );
    }
    if (syncMessage.content?.viewOnceOpen) {
      return this.#handleViewOnceOpen(
        envelope,
        syncMessage.content.viewOnceOpen
      );
    }
    if (syncMessage.content?.messageRequestResponse) {
      return this.#handleMessageRequestResponse(
        envelope,
        syncMessage.content.messageRequestResponse
      );
    }
    if (syncMessage.content?.fetchLatest) {
      return this.#handleFetchLatest(envelope, syncMessage.content.fetchLatest);
    }
    if (syncMessage.content?.keys) {
      return this.#handleKeys(envelope, syncMessage.content.keys);
    }
    if (syncMessage.viewed && syncMessage.viewed.length) {
      return this.#handleViewed(envelope, syncMessage.viewed);
    }
    if (syncMessage.content?.callEvent) {
      return this.#handleCallEvent(envelope, syncMessage.content.callEvent);
    }
    if (syncMessage.content?.callLinkUpdate) {
      return this.#handleCallLinkUpdate(
        envelope,
        syncMessage.content.callLinkUpdate
      );
    }
    if (syncMessage.content?.callLogEvent) {
      return this.#handleCallLogEvent(
        envelope,
        syncMessage.content.callLogEvent
      );
    }
    if (syncMessage.content?.deleteForMe) {
      return this.#handleDeleteForMeSync(
        envelope,
        syncMessage.content.deleteForMe
      );
    }
    if (syncMessage.content?.deviceNameChange) {
      return this.#handleDeviceNameChangeSync(
        envelope,
        syncMessage.content.deviceNameChange
      );
    }
    if (syncMessage.content?.attachmentBackfillResponse) {
      return this.#handleAttachmentBackfillResponse(
        envelope,
        syncMessage.content.attachmentBackfillResponse
      );
    }

    this.#removeFromCache(envelope);
    const envelopeId = getEnvelopeId(envelope);
    const unknownFieldTags = inspectUnknownFieldTags(syncMessage).join(',');
    log.warn(
      `handleSyncMessage/${envelopeId}: Got unknown SyncMessage (Unknown field tags: ${unknownFieldTags})`
    );
  }

  async #handleSentEditMessage(
    envelope: UnsealedEnvelope,
    sentMessage: ProcessedSent
  ): Promise<void> {
    const logId = `handleSentEditMessage(${getEnvelopeId(envelope)})`;
    log.info(logId);

    const { editMessage } = sentMessage;

    if (!editMessage) {
      log.warn(`${logId}: cannot edit message. No editMessage in proto`);
      this.#removeFromCache(envelope);
      return;
    }

    if (!editMessage.targetSentTimestamp) {
      log.warn(`${logId}: cannot edit message. No targetSentTimestamp`);
      this.#removeFromCache(envelope);
      return;
    }

    if (!editMessage.dataMessage) {
      log.warn(`${logId}: cannot edit message. No dataMessage`);
      this.#removeFromCache(envelope);
      return;
    }

    const {
      destinationE164,
      destinationServiceId,
      expirationStartTimestamp,
      unidentifiedStatus,
      isRecipientUpdate,
    } = sentMessage;

    const message = this.#processDecrypted(envelope, editMessage.dataMessage);

    const ev = new SentEvent(
      {
        envelopeId: envelope.id,
        destinationE164: destinationE164 ?? '',
        destinationServiceId,
        timestamp: envelope.timestamp,
        serverTimestamp: envelope.serverTimestamp,
        device: envelope.sourceDevice,
        unidentifiedStatus,
        message: {
          ...message,
          editedMessageTimestamp: toNumber(editMessage.targetSentTimestamp),
        },
        isRecipientUpdate: Boolean(isRecipientUpdate),
        receivedAtCounter: envelope.receivedAtCounter,
        receivedAtDate: envelope.receivedAtDate,
        expirationStartTimestamp: toNumber(expirationStartTimestamp) ?? 0,
      },
      this.#removeFromCache.bind(this, envelope)
    );
    return this.#dispatchAndWait(getEnvelopeId(envelope), ev);
  }

  async #handleConfiguration(
    envelope: ProcessedEnvelope,
    configuration: Proto.SyncMessage.Configuration
  ): Promise<void> {
    const logId = getEnvelopeId(envelope);
    log.info('got configuration sync message', logId);

    logUnexpectedUrgentValue(envelope, 'configurationSync');

    const ev = new ConfigurationEvent(
      configuration,
      this.#removeFromCache.bind(this, envelope)
    );
    return this.#dispatchAndWait(logId, ev);
  }

  async #handleViewOnceOpen(
    envelope: ProcessedEnvelope,
    sync: Proto.SyncMessage.ViewOnceOpen
  ): Promise<void> {
    const logId = getEnvelopeId(envelope);
    log.info('got view once open sync message', logId);

    logUnexpectedUrgentValue(envelope, 'viewOnceSync');

    const ev = new ViewOnceOpenSyncEvent(
      {
        sourceAci: fromAciUuidBytesOrString(
          sync.senderAciBinary,
          sync.senderAci,
          'handleViewOnceOpen.senderUuid'
        ),
        timestamp: toNumber(sync.timestamp) ?? 0,
        envelopeTimestamp: envelope.timestamp,
      },
      this.#removeFromCache.bind(this, envelope)
    );

    return this.#dispatchAndWait(logId, ev);
  }

  async #handleMessageRequestResponse(
    envelope: ProcessedEnvelope,
    sync: Proto.SyncMessage.MessageRequestResponse
  ): Promise<void> {
    const logId = getEnvelopeId(envelope);
    log.info('got message request response sync message', logId);

    logUnexpectedUrgentValue(envelope, 'messageRequestSync');

    const { groupId } = sync;

    let groupV2IdString: string | undefined;
    if (groupId?.byteLength) {
      if (groupId.byteLength === GROUPV2_ID_LENGTH) {
        groupV2IdString = Bytes.toBase64(groupId);
      } else {
        this.#removeFromCache(envelope);
        log.error('Received message request with invalid groupId');
        return undefined;
      }
    }

    const ev = new MessageRequestResponseEvent(
      {
        envelopeId: envelope.id,
        threadAci: fromAciUuidBytesOrString(
          sync.threadAciBinary,
          sync.threadAci,
          'handleMessageRequestResponse.threadUuid'
        ),
        messageRequestResponseType: isKnownProtoEnumMember(
          Proto.SyncMessage.MessageRequestResponse.Type,
          sync.type
        )
          ? sync.type
          : Proto.SyncMessage.MessageRequestResponse.Type.UNKNOWN,
        groupV2Id: groupV2IdString,
        receivedAtCounter: envelope.receivedAtCounter,
        receivedAtMs: envelope.receivedAtDate,
        sentAt: envelope.timestamp,
      },
      this.#removeFromCache.bind(this, envelope)
    );

    return this.#dispatchAndWait(logId, ev);
  }

  async #handleFetchLatest(
    envelope: ProcessedEnvelope,
    sync: Proto.SyncMessage.FetchLatest
  ): Promise<void> {
    const logId = getEnvelopeId(envelope);
    log.info('got fetch latest sync message', logId);

    logUnexpectedUrgentValue(envelope, 'fetchLatestManifestSync');

    const ev = new FetchLatestEvent(
      sync.type,
      this.#removeFromCache.bind(this, envelope)
    );

    return this.#dispatchAndWait(logId, ev);
  }

  async #handleKeys(
    envelope: ProcessedEnvelope,
    sync: Proto.SyncMessage.Keys
  ): Promise<void> {
    const logId = getEnvelopeId(envelope);
    log.info('got keys sync message', logId);

    logUnexpectedUrgentValue(envelope, 'keySync');

    const ev = new KeysEvent(
      {
        masterKey: Bytes.isNotEmpty(sync.master) ? sync.master : undefined,
        accountEntropyPool: sync.accountEntropyPool || undefined,
        mediaRootBackupKey: Bytes.isNotEmpty(sync.mediaRootBackupKey)
          ? sync.mediaRootBackupKey
          : undefined,
      },
      this.#removeFromCache.bind(this, envelope)
    );

    return this.#dispatchAndWait(logId, ev);
  }

  // Runs on TaskType.Encrypted queue
  async #handlePNIChangeNumber(
    envelope: ProcessedEnvelope,
    {
      identityKeyPair,
      signedPreKey,
      lastResortKyberPreKey,
      registrationId,
      newE164,
    }: Proto.SyncMessage.PniChangeNumber
  ): Promise<void> {
    const ourAci = this.#storage.user.getCheckedAci();

    if (envelope.sourceServiceId !== ourAci) {
      throw new Error('Received pni change number from another number');
    }

    log.info('got pni change number sync message');

    logUnexpectedUrgentValue(envelope, 'pniIdentitySync');

    const { updatedPni } = envelope;
    if (!updatedPni) {
      log.warn('missing pni in change number sync message');
      return;
    }

    // TDOO: DESKTOP-5652
    if (
      !Bytes.isNotEmpty(identityKeyPair) ||
      !Bytes.isNotEmpty(signedPreKey) ||
      !isNumber(registrationId) ||
      !isString(newE164)
    ) {
      log.warn('empty pni change number sync message');
      return;
    }

    if (this.#pniIdentityKeyCheckRequired) {
      log.warn('canceling pni identity key check');
    }
    this.#pniIdentityKeyCheckRequired = false;

    await accountManager.setPni(updatedPni, {
      identityKeyPair,
      lastResortKyberPreKey: dropNull(lastResortKyberPreKey),
      signedPreKey,
      registrationId,
    });
    await this.#storage.user.setNumber(newE164);
  }

  async #handleStickerPackOperation(
    envelope: ProcessedEnvelope,
    operations: Array<Proto.SyncMessage.StickerPackOperation>
  ): Promise<void> {
    const ENUM = Proto.SyncMessage.StickerPackOperation.Type;
    const logId = getEnvelopeId(envelope);
    log.info('got sticker pack operation sync message', logId);
    logUnexpectedUrgentValue(envelope, 'stickerPackSync');

    const stickerPacks = operations.map(operation => ({
      id: operation.packId ? Bytes.toHex(operation.packId) : undefined,
      key: operation.packKey ? Bytes.toBase64(operation.packKey) : undefined,
      isInstall: operation.type === ENUM.INSTALL,
      isRemove: operation.type === ENUM.REMOVE,
    }));

    const ev = new StickerPackEvent(
      stickerPacks,
      this.#removeFromCache.bind(this, envelope)
    );

    return this.#dispatchAndWait(logId, ev);
  }

  async #handleRead(
    envelope: ProcessedEnvelope,
    read: Array<Proto.SyncMessage.Read>
  ): Promise<void> {
    const logId = getEnvelopeId(envelope);
    log.info('handleRead', logId);

    logUnexpectedUrgentValue(envelope, 'readSync');

    const reads = read.map((data): ReadSyncEventData => {
      const { timestamp, senderAci: rawSenderAci, senderAciBinary } = data;

      return {
        envelopeId: envelope.id,
        envelopeTimestamp: envelope.timestamp,
        timestamp: toNumber(timestamp) ?? 0,
        senderAci: fromAciUuidBytesOrString(
          senderAciBinary,
          rawSenderAci,
          'handleRead.senderAci'
        ),
      };
    });

    await this.#dispatchAndWait(
      logId,
      new ReadSyncEvent(
        reads,
        envelope.id,
        envelope.timestamp,
        this.#removeFromCache.bind(this, envelope)
      )
    );
  }

  async #handleViewed(
    envelope: ProcessedEnvelope,
    viewed: ReadonlyArray<Proto.SyncMessage.Viewed>
  ): Promise<void> {
    const logId = getEnvelopeId(envelope);
    log.info('handleViewed', logId);

    logUnexpectedUrgentValue(envelope, 'viewSync');

    const views = viewed.map((data): ViewSyncEventData => {
      const { timestamp, senderAci: rawSenderAci, senderAciBinary } = data;

      return {
        timestamp: toNumber(timestamp) ?? 0,
        senderAci: fromAciUuidBytesOrString(
          senderAciBinary,
          rawSenderAci,
          'handleViewed.senderAci'
        ),
      };
    });

    await this.#dispatchAndWait(
      logId,
      new ViewSyncEvent(
        views,
        envelope.id,
        envelope.timestamp,
        this.#removeFromCache.bind(this, envelope)
      )
    );
  }

  async #handleCallEvent(
    envelope: ProcessedEnvelope,
    callEvent: Proto.SyncMessage.CallEvent
  ): Promise<void> {
    const logId = getEnvelopeId(envelope);
    log.info('handleCallEvent', logId);

    logUnexpectedUrgentValue(envelope, 'callEventSync');

    const { receivedAtCounter, receivedAtDate: receivedAtMS } = envelope;

    const callEventDetails = getCallEventForProto(
      callEvent,
      'MessageReceiver.handleCallEvent'
    );

    const callEventSync = new CallEventSyncEvent(
      {
        callEventDetails,
        receivedAtCounter,
        receivedAtMS,
      },
      this.#removeFromCache.bind(this, envelope)
    );
    await this.#dispatchAndWait(logId, callEventSync);

    log.info('handleCallEvent: finished');
  }

  async #handleCallLinkUpdate(
    envelope: ProcessedEnvelope,
    callLinkUpdate: Proto.SyncMessage.CallLinkUpdate
  ): Promise<void> {
    const logId = getEnvelopeId(envelope);
    log.info('handleCallLinkUpdate', logId);

    logUnexpectedUrgentValue(envelope, 'callLinkUpdateSync');

    let callLinkUpdateSyncType: CallLinkUpdateSyncType;
    if (callLinkUpdate.type == null) {
      throw new Error('MessageReceiver.handleCallLinkUpdate: type was null');
    } else if (
      callLinkUpdate.type === Proto.SyncMessage.CallLinkUpdate.Type.UPDATE
    ) {
      callLinkUpdateSyncType = CallLinkUpdateSyncType.Update;
    } else {
      throw new Error(
        `MessageReceiver.handleCallLinkUpdate: unknown type ${callLinkUpdate.type}`
      );
    }

    const rootKey = Bytes.isNotEmpty(callLinkUpdate.rootKey)
      ? callLinkUpdate.rootKey
      : undefined;
    const adminKey = Bytes.isNotEmpty(callLinkUpdate.adminPasskey)
      ? callLinkUpdate.adminPasskey
      : undefined;

    const ev = new CallLinkUpdateSyncEvent(
      {
        type: callLinkUpdateSyncType,
        rootKey,
        adminKey,
      },
      this.#removeFromCache.bind(this, envelope)
    );

    await this.#dispatchAndWait(logId, ev);

    log.info('handleCallLinkUpdate: finished');
  }

  async #handleCallLogEvent(
    envelope: ProcessedEnvelope,
    callLogEvent: Proto.SyncMessage.CallLogEvent
  ): Promise<void> {
    const logId = getEnvelopeId(envelope);
    log.info('handleCallLogEvent', logId);

    logUnexpectedUrgentValue(envelope, 'callLogEventSync');

    const { receivedAtCounter } = envelope;

    const callLogEventDetails = getCallLogEventForProto(callLogEvent);
    const callLogEventSync = new CallLogEventSyncEvent(
      {
        callLogEventDetails,
        receivedAtCounter,
      },
      this.#removeFromCache.bind(this, envelope)
    );

    await this.#dispatchAndWait(logId, callLogEventSync);

    log.info('handleCallLogEvent: finished');
  }

  async #handleDeleteForMeSync(
    envelope: ProcessedEnvelope,
    deleteSync: Proto.SyncMessage.DeleteForMe
  ): Promise<void> {
    const logId = getEnvelopeId(envelope);
    log.info('handleDeleteForMeSync', logId);

    logUnexpectedUrgentValue(envelope, 'deleteForMeSync');

    const { timestamp } = envelope;
    let eventData: DeleteForMeSyncEventData = [];

    try {
      if (deleteSync.messageDeletes?.length) {
        const messageDeletes: Array<DeleteForMeSyncTarget> =
          deleteSync.messageDeletes
            .flatMap((item): Array<DeleteForMeSyncTarget> | undefined => {
              const messages = item.messages
                ?.map(message => processAddressableMessage(message, logId))
                .filter(isNotNil);
              const conversation = item.conversation
                ? processConversationIdentifier(item.conversation, logId)
                : undefined;

              if (!conversation) {
                log.warn(
                  `${logId}/handleDeleteForMeSync/messageDeletes: No target conversation`
                );
                return undefined;
              }
              if (!messages?.length) {
                log.warn(
                  `${logId}/handleDeleteForMeSync/messageDeletes: No target messages`
                );
                return undefined;
              }

              // We want each message in its own task
              return messages.map(innerItem => {
                return {
                  type: 'delete-message' as const,
                  message: innerItem,
                  conversation,
                  timestamp,
                };
              });
            })
            .filter(isNotNil);

        eventData = eventData.concat(messageDeletes);
      }
      if (deleteSync.conversationDeletes?.length) {
        const conversationDeletes: Array<DeleteForMeSyncTarget> =
          deleteSync.conversationDeletes
            .map(item => {
              const mostRecentMessages = item.mostRecentMessages
                ?.map(message => processAddressableMessage(message, logId))
                .filter(isNotNil);
              const mostRecentNonExpiringMessages =
                item.mostRecentNonExpiringMessages
                  ?.map(message => processAddressableMessage(message, logId))
                  .filter(isNotNil);
              const conversation = item.conversation
                ? processConversationIdentifier(item.conversation, logId)
                : undefined;

              if (!conversation) {
                log.warn(
                  `${logId}/handleDeleteForMeSync/conversationDeletes: No target conversation`
                );
                return undefined;
              }
              if (!mostRecentMessages?.length) {
                log.warn(
                  `${logId}/handleDeleteForMeSync/conversationDeletes: No target messages`
                );
                return undefined;
              }

              return {
                type: 'delete-conversation' as const,
                conversation,
                isFullDelete: Boolean(item.isFullDelete),
                mostRecentMessages,
                mostRecentNonExpiringMessages,
                timestamp,
              };
            })
            .filter(isNotNil);

        eventData = eventData.concat(conversationDeletes);
      }
      if (deleteSync.localOnlyConversationDeletes?.length) {
        const localOnlyConversationDeletes: Array<DeleteForMeSyncTarget> =
          deleteSync.localOnlyConversationDeletes
            .map(item => {
              const conversation = item.conversation
                ? processConversationIdentifier(item.conversation, logId)
                : undefined;

              if (!conversation) {
                log.warn(
                  `${logId}/handleDeleteForMeSync/localOnlyConversationDeletes: No target conversation`
                );
                return undefined;
              }

              return {
                type: 'delete-local-conversation' as const,
                conversation,
                timestamp,
              };
            })
            .filter(isNotNil);

        eventData = eventData.concat(localOnlyConversationDeletes);
      }
      if (deleteSync.attachmentDeletes?.length) {
        const attachmentDeletes: Array<DeleteForMeSyncTarget> =
          deleteSync.attachmentDeletes
            .map(item => {
              const {
                clientUuid: targetClientUuid,
                conversation: targetConversation,
                fallbackDigest: targetFallbackDigest,
                fallbackPlaintextHash: targetFallbackPlaintextHash,
                targetMessage,
              } = item;
              const conversation = targetConversation
                ? processConversationIdentifier(targetConversation, logId)
                : undefined;
              const message = targetMessage
                ? processAddressableMessage(targetMessage, logId)
                : undefined;

              if (!conversation) {
                log.warn(
                  `${logId}/handleDeleteForMeSync/attachmentDeletes: No target conversation`
                );
                return undefined;
              }
              if (!message) {
                log.warn(
                  `${logId}/handleDeleteForMeSync/attachmentDeletes: No target message`
                );
                return undefined;
              }
              const clientUuid = targetClientUuid?.length
                ? bytesToUuid(targetClientUuid)
                : undefined;
              const fallbackDigest = targetFallbackDigest?.length
                ? Bytes.toBase64(targetFallbackDigest)
                : undefined;
              // TODO: DESKTOP-7204
              const fallbackPlaintextHash = targetFallbackPlaintextHash?.length
                ? Bytes.toHex(targetFallbackPlaintextHash)
                : undefined;
              if (!clientUuid && !fallbackDigest && !fallbackPlaintextHash) {
                log.warn(
                  `${logId}/handleDeleteForMeSync/attachmentDeletes: Missing clientUuid, fallbackDigest and fallbackPlaintextHash`
                );
                return undefined;
              }

              return {
                type: 'delete-single-attachment' as const,
                conversation,
                message,
                clientUuid,
                fallbackDigest,
                fallbackPlaintextHash,
                timestamp,
              };
            })
            .filter(isNotNil);

        eventData = eventData.concat(attachmentDeletes);
      }
      if (!eventData.length) {
        throw new Error(`${logId}: Nothing found in sync message!`);
      }
    } catch (error: unknown) {
      this.#removeFromCache(envelope);

      throw error;
    }

    const deleteSyncEventSync = new DeleteForMeSyncEvent(
      eventData,
      timestamp,
      envelope.id,
      this.#removeFromCache.bind(this, envelope)
    );

    await this.#dispatchAndWait(logId, deleteSyncEventSync);

    log.info('handleDeleteForMeSync: finished');
  }

  async #handleAttachmentBackfillResponse(
    envelope: ProcessedEnvelope,
    response: Proto.SyncMessage.AttachmentBackfillResponse
  ): Promise<void> {
    const logId = getEnvelopeId(envelope);
    log.info('handleAttachmentBackfillResponse', logId);

    logUnexpectedUrgentValue(envelope, 'attachmentBackfillResponseSync');

    strictAssert(
      response.targetMessage != null,
      'MessageReceiver.handleAttachmentBackfillResponse: no target message'
    );
    strictAssert(
      response.targetConversation != null,
      'MessageReceiver.handleAttachmentBackfillResponse: no target conversation'
    );

    const targetMessage = processAddressableMessage(
      response.targetMessage,
      logId
    );

    const targetConversation = processConversationIdentifier(
      response.targetConversation,
      logId
    );

    strictAssert(
      targetMessage != null,
      'MessageReceiver.handleAttachmentBackfillResponse: invalid target message'
    );
    strictAssert(
      targetConversation != null,
      'MessageReceiver.handleAttachmentBackfillResponse: ' +
        'invalid target conversation'
    );

    let eventData: AttachmentBackfillResponseSyncEventData;
    if (response.data?.error != null) {
      eventData = {
        error: isKnownProtoEnumMember(
          Proto.SyncMessage.AttachmentBackfillResponse.Error,
          response.data.error
        )
          ? response.data.error
          : 0,
        targetMessage,
        targetConversation,
      };
    } else {
      const attachments = response.data?.attachments;
      strictAssert(
        attachments != null,
        'MessageReceiver.handleAttachmentBackfillResponse: no attachments'
      );

      eventData = {
        targetMessage,
        targetConversation,
        longText:
          attachments.longText == null
            ? undefined
            : processBackfilledAttachment(attachments.longText),
        attachments: (attachments.attachments ?? []).map(
          processBackfilledAttachment
        ),
      };
    }

    const attachmentBackfillResponseSync =
      new AttachmentBackfillResponseSyncEvent(
        eventData,
        envelope.timestamp,
        envelope.id,
        this.#removeFromCache.bind(this, envelope)
      );

    await this.#dispatchAndWait(logId, attachmentBackfillResponseSync);

    log.info('handleAttachmentBackfillResponse: finished');
  }

  async #handleDeviceNameChangeSync(
    envelope: ProcessedEnvelope,
    deviceNameChange: Proto.SyncMessage.DeviceNameChange
  ): Promise<void> {
    const logId = `handleDeviceNameChangeSync: ${getEnvelopeId(envelope)}`;
    log.info(logId);

    logUnexpectedUrgentValue(envelope, 'deviceNameChangeSync');

    const { deviceId } = deviceNameChange;
    const localDeviceId = parseIntOrThrow(
      this.#storage.user.getDeviceId(),
      'MessageReceiver.handleDeviceNameChangeSync: localDeviceId'
    );

    if (deviceId == null) {
      log.warn(logId, 'deviceId was falsey');
      this.#removeFromCache(envelope);
      return;
    }

    if (deviceId !== localDeviceId) {
      log.info(logId, 'meant for other device:', deviceId);
      this.#removeFromCache(envelope);
      return;
    }

    const deviceNameChangeEvent = new DeviceNameChangeSyncEvent(
      this.#removeFromCache.bind(this, envelope)
    );
    await this.#dispatchAndWait(logId, deviceNameChangeEvent);
  }

  async #handleContacts(
    envelope: ProcessedEnvelope,
    contactSyncProto: Proto.SyncMessage.Contacts
  ): Promise<void> {
    const logId = getEnvelopeId(envelope);
    log.info(`handleContacts ${logId}`);
    const { blob } = contactSyncProto;
    if (!blob) {
      throw new Error('MessageReceiver.handleContacts: blob field was missing');
    }

    logUnexpectedUrgentValue(envelope, 'contactSync');

    this.#removeFromCache(envelope);

    const contactSync = new ContactSyncEvent(
      processAttachment(blob),
      Boolean(contactSyncProto.complete),
      envelope.receivedAtCounter,
      envelope.timestamp
    );
    await this.#dispatchAndWait(logId, contactSync);
  }

  // This function calls applyMessageRequestResponse before setting storage so
  // proper before/after logic can be applied within that function.
  async #handleBlocked(
    envelope: ProcessedEnvelope,
    blocked: Proto.SyncMessage.Blocked
  ): Promise<void> {
    const logId = `handleBlocked(${getEnvelopeId(envelope)})`;
    const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type;

    logUnexpectedUrgentValue(envelope, 'blockSync');

    const responseInfo: MessageRequestResponseInfo = {
      source: MessageRequestResponseSource.BLOCK_SYNC,
      receivedAtCounter: envelope.receivedAtCounter,
      receivedAtMs: envelope.receivedAtDate,
      timestamp: envelope.timestamp,
    };

    function getAndApply(
      type: Proto.SyncMessage.MessageRequestResponse.Type
    ): (value: string) => Promise<void> {
      return async item => {
        const conversation = window.ConversationController.getOrCreate(
          item,
          'private'
        );
        await conversation.applyMessageRequestResponse(type, responseInfo);
      };
    }

    if (blocked.numbers) {
      const previous = this.#storage.get('blocked', []);

      const { added, removed } = diffArraysAsSets(previous, blocked.numbers);
      if (added.length) {
        await Promise.all(added.map(getAndApply(messageRequestEnum.BLOCK)));
      }
      if (removed.length) {
        await Promise.all(removed.map(getAndApply(messageRequestEnum.ACCEPT)));
      }

      log.info(`${logId}: New e164 blocks:`, added);
      log.info(`${logId}: New e164 unblocks:`, removed);
      await this.#storage.put('blocked', blocked.numbers);
    }
    if (blocked.acisBinary?.length || blocked.acis?.length) {
      const previous = this.#storage.get('blocked-uuids', []);
      let acis: Array<AciString>;
      if (blocked.acisBinary?.length) {
        acis = blocked.acisBinary
          .map((aciBinary, index) => {
            try {
              return fromAciUuidBytes(aciBinary);
            } catch (error) {
              log.warn(
                `${logId}: ACI ${index} was malformed`,
                Errors.toLogFormat(error)
              );
              return undefined;
            }
          })
          .filter(isNotNil);
      } else if (blocked.acis?.length) {
        acis = blocked.acis
          .map((aci, index) => {
            try {
              return normalizeAci(aci, `handleBlocked.acis.${index}`);
            } catch (error) {
              log.warn(
                `${logId}: ACI ${aci} was malformed`,
                Errors.toLogFormat(error)
              );
              return undefined;
            }
          })
          .filter(isNotNil);
      } else {
        throw new Error('No blocked acis');
      }

      const { added, removed } = diffArraysAsSets(previous, acis);
      if (added.length) {
        await Promise.all(added.map(getAndApply(messageRequestEnum.BLOCK)));
      }
      if (removed.length) {
        await Promise.all(removed.map(getAndApply(messageRequestEnum.ACCEPT)));
      }

      log.info(`${logId}: New aci blocks:`, added);
      log.info(`${logId}: New aci unblocks:`, removed);
      await this.#storage.put('blocked-uuids', acis);
    }

    if (blocked.groupIds) {
      const previous = this.#storage.get('blocked-groups', []);
      const groupIds: Array<string> = [];

      blocked.groupIds.forEach(groupId => {
        if (groupId.byteLength === GROUPV2_ID_LENGTH) {
          groupIds.push(Bytes.toBase64(groupId));
        } else {
          log.error(`${logId}: Received invalid groupId value`);
        }
      });

      const { added, removed } = diffArraysAsSets(previous, groupIds);
      if (added.length) {
        await Promise.all(
          added.map(async item => {
            const conversation = window.ConversationController.get(item);
            if (!conversation) {
              log.warn(`${logId}: Group groupv2(${item}) not found!`);
              return;
            }
            await conversation.applyMessageRequestResponse(
              messageRequestEnum.BLOCK,
              responseInfo
            );
          })
        );
      }
      if (removed.length) {
        await Promise.all(
          removed.map(async item => {
            const conversation = window.ConversationController.get(item);
            if (!conversation) {
              log.warn(`${logId}: Group groupv2(${item}) not found!`);
              return;
            }
            await conversation.applyMessageRequestResponse(
              messageRequestEnum.ACCEPT,
              responseInfo
            );
          })
        );
      }

      log.info(
        `${logId}: New groupId blocks:`,
        added.map(groupId => `groupv2(${groupId})`)
      );
      log.info(
        `${logId}: New groupId unblocks:`,
        removed.map(groupId => `groupv2(${groupId})`)
      );
      await this.#storage.put('blocked-groups', groupIds);
    }

    this.#removeFromCache(envelope);
  }

  #isBlocked(number: string): boolean {
    return this.#storage.blocked.isBlocked(number);
  }

  #isServiceIdBlocked(serviceId: ServiceIdString): boolean {
    return this.#storage.blocked.isServiceIdBlocked(serviceId);
  }

  #isGroupBlocked(groupId: string): boolean {
    return this.#storage.blocked.isGroupBlocked(groupId);
  }

  async #handleEndSession(
    envelope: ProcessedEnvelope,
    theirServiceId: ServiceIdString
  ): Promise<void> {
    log.info(`handleEndSession: closing sessions for ${theirServiceId}`);

    logUnexpectedUrgentValue(envelope, 'resetSession');

    await signalProtocolStore.archiveAllSessions(theirServiceId);
  }

  #processDecrypted(
    envelope: ProcessedEnvelope,
    decrypted: Proto.DataMessage
  ): ProcessedDataMessage {
    return processDataMessage(decrypted, envelope.timestamp);
  }
}

function envelopeTypeToCiphertextType(type: number | undefined): number {
  const { Type } = Proto.Envelope;

  if (type === Type.DOUBLE_RATCHET) {
    return CiphertextMessageType.Whisper;
  }
  if (type === Type.PLAINTEXT_CONTENT) {
    return CiphertextMessageType.Plaintext;
  }
  if (type === Type.PREKEY_MESSAGE) {
    return CiphertextMessageType.PreKey;
  }
  if (type === Type.SERVER_DELIVERY_RECEIPT) {
    return CiphertextMessageType.Plaintext;
  }
  if (type === Type.UNIDENTIFIED_SENDER) {
    throw new Error(
      'envelopeTypeToCiphertextType: Cannot process UNIDENTIFIED_SENDER messages'
    );
  }
  if (type === Type.UNKNOWN) {
    throw new Error(
      'envelopeTypeToCiphertextType: Cannot process UNKNOWN messages'
    );
  }

  throw new Error(`envelopeTypeToCiphertextType: Unknown type ${type}`);
}

function processAddressableMessage(
  target: Proto.AddressableMessage,
  logId: string
): AddressableMessage | undefined {
  if (target.author == null) {
    log.error(`${logId}/processAddressableMessage: no author`);
    return undefined;
  }
  const sentAt = toNumber(target.sentTimestamp);
  if (!isNumber(sentAt)) {
    log.warn(
      `${logId}/processAddressableMessage: No sentTimestamp found! Dropping AddressableMessage.`
    );
    return undefined;
  }

  if (target.author.authorE164 != null) {
    return {
      type: 'e164' as const,
      authorE164: target.author.authorE164,
      sentAt,
    };
  }

  const { authorServiceId: rawAuthorServiceId, authorServiceIdBinary } =
    target.author;

  const authorServiceId = fromServiceIdBinaryOrString(
    authorServiceIdBinary,
    rawAuthorServiceId,
    logId
  );

  if (authorServiceId) {
    if (isAciString(authorServiceId)) {
      return {
        type: 'aci' as const,
        authorAci: authorServiceId,
        sentAt,
      };
    }
    if (isPniString(authorServiceId)) {
      return {
        type: 'pni' as const,
        authorPni: authorServiceId,
        sentAt,
      };
    }
    log.error(
      `${logId}/processAddressableMessage: invalid authorServiceId, Dropping AddressableMessage.`
    );
    return undefined;
  }

  log.warn(
    `${logId}/processAddressableMessage: No author field found! Dropping AddressableMessage.`
  );
  return undefined;
}

function processConversationIdentifier(
  target: Proto.ConversationIdentifier,
  logId: string
): ConversationIdentifier | undefined {
  if (target.identifier == null) {
    log.error(`${logId}/processConversationIdentifier: no identifier`);
    return undefined;
  }
  const {
    threadServiceId: rawThreadServiceId,
    threadServiceIdBinary,
    threadGroupId,
    threadE164,
  } = target.identifier;

  const threadServiceId = fromServiceIdBinaryOrString(
    threadServiceIdBinary,
    rawThreadServiceId,
    logId
  );

  if (threadServiceId) {
    if (isAciString(threadServiceId)) {
      return {
        type: 'aci' as const,
        aci: threadServiceId,
      };
    }
    if (isPniString(threadServiceId)) {
      return {
        type: 'pni' as const,
        pni: threadServiceId,
      };
    }
    log.error(
      `${logId}/processConversationIdentifier: Invalid threadServiceId, dropping ConversationIdentifier.`
    );
    return undefined;
  }
  if (threadGroupId) {
    return {
      type: 'group' as const,
      groupId: Bytes.toBase64(threadGroupId),
    };
  }
  if (threadE164) {
    return {
      type: 'e164' as const,
      e164: threadE164,
    };
  }

  log.warn(
    `${logId}/processConversationIdentifier: No identifier field found! Dropping ConversationIdentifier.`
  );
  return undefined;
}

function processBackfilledAttachment(
  data: Proto.SyncMessage.AttachmentBackfillResponse.AttachmentData
): AttachmentBackfillAttachmentType {
  if (data.data?.status != null) {
    return {
      status: isKnownProtoEnumMember(
        Proto.SyncMessage.AttachmentBackfillResponse.AttachmentData.Status,
        data.data.status
      )
        ? data.data.status
        : 0,
    };
  }

  const attachment = processAttachment(data.data?.attachment);
  strictAssert(
    attachment != null,
    'MessageReceiver.handleAttachmentBackfillResponse: ' +
      'invalid attachment pointer'
  );
  return {
    attachment: {
      ...attachment,

      // Resumable downloads
      downloadPath: createName(),
    },
  };
}
