Wednesday, July 15, 2026 2:54:29 AM
> lifecycle.rb
# frozen_string_literal: true

module ESM
  module Arma
    class Client
      module Lifecycle
        VALID_REQUEST_TYPES = %w[
          send_to_channel
          send_xm8_notification
        ]

        private

        def on_message
          request = read
          return if request.nil?

          trace!(
            address:,
            public_id:,
            state: :request_received,
            type: request.type,
            request_id: request.id
          )

          @thread_pool.post do
            ESM::Database.with_connection do
              process_message(request)
            end
          end
        end

        def process_message(request)
          if @ledger.include?(request)
            forward_to_caller(request)
            return
          end

          case request.type
          when :identification
            on_identification(request.content)
          when :message
            on_request(request.content)
          end
        rescue ESM::Exception::InvalidAccessKey
          warn!(
            address:,
            state: :auth_rejected,
            reason: :invalid_access_key
          )
          close
        rescue ESM::Exception::ClosableError => e
          warn!(address:, public_id:, error: e)
          close
        rescue ESM::Exception::SendableError => e
          send_error(e.data)
        rescue ESM::Exception::RequestTimeout
          nil # Ignore
        rescue => e
          error!(error: e)
        ensure
          @ledger.remove(request)
        end

        def forward_to_caller(request)
          promise = @ledger.remove(request)
          raise ESM::Exception::InvalidMessage if promise.nil?

          promise.set_response(request)
        end

        def on_identification(public_id)
          info!(address:, state: :on_identification, public_id:)

          existing_connection = ESM::Arma::Server.client(public_id)
          raise ESM::Exception::ExistingConnection if existing_connection

          model = ESM::Server.find_by_public_id(public_id)
          raise ESM::Exception::InvalidAccessKey if model.nil?

          authenticate!(model)
          initialize!(model)
        end

        def authenticate!(model)
          @public_id = model.public_id
          @server_id = model.server_id
          @session_id = SecureRandom.uuid

          secret_key = model.server_key

          # Do not set the session ID on this
          @encryption = Encryption.new(secret_key)

          # Generate new nonce indices for the client
          nonce_indices = Encryption.generate_nonce_indices

          message = ESM::Message.new
            .set_type(:init)
            .set_data(indices: nonce_indices, session_id:)

          info!(
            address:,
            public_id:,
            server_id:,
            state: :handshake_dispatch,
            timeout: @config.response_timeout
          )

          # This doesn't use #send_request because the nonce must swap to the new one
          # between sending the handshake and reading the client's response. #write
          # encrypts and sends the handshake with the current key, then we swap so the
          # read loop decrypts the response with the new one. The network round-trip
          # dwarfs the gap, so this isn't a practical race.
          promise = write(id: message.id, type: :handshake, content: message.to_s)
          @encryption = Encryption.new(secret_key, nonce_indices:, session_id:)

          response = promise.wait_for_response(@config.response_timeout)

          if response.rejected?
            warn!(
              address:,
              public_id:,
              server_id:,
              state: :handshake_rejected,
              reason: response.reason
            )
            raise ESM::Exception::RejectedPromise, response.reason
          end

          info!(
            address:,
            public_id:,
            server_id:,
            state: :handshake_complete
          )

          # Ledger doesn't care what object it is, so long as it responds to #id
          @ledger.remove(message)
          nil
        end

        def initialize!(model)
          info!(
            address:,
            public_id:,
            server_id:,
            state: :initialize_dispatch
          )

          message = send_request(type: :initialize)
          ESM::Event::ServerInitialization.new(self, model, message).run!

          ESM::Arma::Server.on_initialize(self)
        end

        def check_for_valid_request!(message)
          return if message.type == :call &&
            VALID_REQUEST_TYPES.include?(message.data.function_name)

          raise ESM::Exception::InvalidRequest, "Invalid request received. Read the docs!"
        end

        def on_disconnect(reason = "")
          return if @public_id.nil?

          model = ESM::Server.find_by_public_id(@public_id)
          return if model.nil?

          reason =
            if reason.present?
              reason
            elsif ESM.discord_bot.stopping?
              I18n.t("server_disconnect.reasons.restart")
            end

          embed = model.status_embed(:disconnected, reason:)
          model.community.log_event(:reconnect, embed)

          info!(public_id:, server_id:, uptime: model.uptime, reason:)

          model.update(server_start_time: nil, disconnected_at: ESM::Time.now)
        end

        def on_request(content)
          message = ESM::Message.from_string(content)
          info!(address:, public_id:, server_id:, inbound: message.to_h)

          check_for_valid_request!(message)

          model = ESM::Server.find_by_public_id(@public_id)

          event_class =
            case message.data.function_name
            when "send_to_channel"
              Discord::Event::SendToChannel
            when "send_xm8_notification"
              Event::SendXm8Notification
            end

          event_class.new(model, message).run!
        end
      end
    end
  end
end
All opinions represented herein are my own
- © 2024 - 2026 itsthedevman
- build 43aa0d7