Wednesday, July 15, 2026 2:05:26 AM
> xm8_notification.rb
# frozen_string_literal: true

module ESM
  attributes = %i[id recipient_notification_mapping server content created_at]

  class Xm8Notification < Struct.new(*attributes)
    TYPES = {
      "base-raid": BaseRaid,
      "charge-plant-started": ChargePlantStarted,
      custom: Custom,
      "flag-restored": FlagRestored,
      "flag-steal-started": FlagStealStarted,
      "flag-stolen": FlagStolen,
      "grind-started": GrindStarted,
      "hack-started": HackStarted,
      "marxet-item-sold": MarxetItemSold,
      "protection-money-due": ProtectionMoneyDue,
      "protection-money-paid": ProtectionMoneyPaid
    }.with_indifferent_access.freeze

    # Must match the ENUM in MySQL for xm8_notifications
    STATES = [
      STATE_NEW = "new",
      STATE_PENDING = "pending",
      STATE_FAILED = "failed",
      STATE_SENT = "sent"
    ]

    DETAILS_NOT_REGISTERED = "recipient not registered"
    DETAILS_DM = "direct message"
    DETAILS_CUSTOM = "custom route(s)"
    DETAILS_NO_DESTINATION = "#{DETAILS_DM} disallowed, no #{DETAILS_CUSTOM}"

    class InvalidType < Exception::Error
    end

    class InvalidContent < Exception::Error
    end

    def self.from(hash)
      type = hash[:type]
      klass = TYPES[type]
      raise InvalidType, "\"#{type}\" is not a valid XM8 notification type" if klass.nil?

      hash[:content] = hash[:content].to_ostruct

      notification = klass.new(**hash.without(:type))
      notification.validate!
      notification
    end

    def self.failed_state(state_details)
      {state: Xm8Notification::STATE_FAILED, state_details:}
    end

    def self.sent_state(state_details)
      {state: Xm8Notification::STATE_SENT, state_details:}
    end

    def initialize(**opts)
      # ID is internal for tracking purposes
      opts[:id] = SecureRandom.uuid

      super

      # Notification generation data
      @context = {
        communityid: server.community.community_id,
        serverid: server.server_id,
        servername: server.server_name,
        territoryid: content.territory_id || "",
        territoryname: content.territory_name || "",
        item: content.item_name || "",
        amount: content.poptabs_received || ""
      }
    end

    def type
      @type ||= self.class.name.demodulize.underscore.dasherize
    end

    def send_to_recipients
      states = Hash.new { |hash, uuid| hash[uuid] = [] }

      user_ids = recipient_notification_mapping.keys.map(&:id)

      send_to_dm(states, user_ids)
      send_to_custom_routes(states, user_ids)
      process_undeliverable_notifications(states)

      info!(states)

      update_notification_states(states)

      nil
    end

    def to_embed
      embed = ESM::Notification.build_random(
        community_id: server.community.id,
        type:,
        category: "xm8",
        **@context
      )

      embed.footer = "[#{server.server_id}] #{server.server_name}"
      embed
    end

    def validate!
      raise InvalidContent unless valid?
    end

    private

    def valid?
      # Most notifications are about a territory
      content.territory_id.present? && content.territory_name.present?
    end

    def send_to_dm(states, user_ids)
      preferences_by_user_id = ESM::UserNotificationPreference.where(user_id: user_ids)
        .pluck(:user_id, Arel.sql(type.underscore))
        .to_h

      # Default the preference to allow.
      # This is needed if the user hasn't ran the preference command before
      preferences_by_user_id.default = true

      recipient_notification_mapping.each do |user, uuid|
        dm_allowed = preferences_by_user_id[user.id]
        next unless dm_allowed

        message = ESM.discord_bot.deliver(to_embed, to: user.discord_user, block: true)

        states[uuid] << {detail: DETAILS_DM, sent: !!message}
      end
    end

    # Custom routes are a little different.
    #   Using a mention in an embed does not cause a "notification" on discord.
    #     This does not work since these are often urgent.
    #   To get around this, routes need to be grouped by channel.
    #   From here, an initial message can be sent tagging each user with this channel (and type)
    def send_to_custom_routes(states, user_ids)
      user_lookup = recipient_notification_mapping.keys.to_h { |u| [u.id, u] }

      users_by_channel_id = ESM::UserNotificationRoute.enabled
        .accepted
        .where(notification_type: type, user_id: user_ids)
        .where("source_server_id IS NULL OR source_server_id = ?", server.id)
        .pluck(:channel_id, :user_id)
        .group_by(&:first)
        .transform_values { |g| g.map { |(_channel_id, user_id)| user_lookup[user_id] } }

      users_by_channel_id.each do |channel_id, users|
        message = ESM.discord_bot.deliver(
          to_embed,
          to: channel_id,
          embed_message: "#{type.titleize} - #{users.map(&:mention).join(" ")}",
          block: true
        )

        notification_uuids = users.map { |u| recipient_notification_mapping[u] }
        notification_uuids.each do |uuid|
          states[uuid] << {detail: DETAILS_CUSTOM, sent: !!message}
        end
      end
    end

    # Handle notifications in which the recipient has blocked direct message
    # and has no custom routes for the notification to be sent to.
    def process_undeliverable_notifications(states)
      recipient_notification_mapping.values.each do |uuid|
        next if states.key?(uuid)

        states[uuid] << {detail: DETAILS_NO_DESTINATION, sent: false}
      end
    end

    def update_notification_states(states)
      state_update = states.transform_values { |outcomes| collapse_state(outcomes) }

      message = ESM::Message.new
        .set_type(:query)
        .set_data(
          query_function_name: "update_xm8_notification_state",
          **state_update
        )

      server.send_message(message, block: false)
    end

    # Reaching the recipient on any destination counts as sent; a stale custom
    # route must not brand an otherwise-delivered notification failed.
    def collapse_state(outcomes)
      delivered, failed = outcomes
        .partition { |outcome| outcome[:sent] }
        .map { |group| group.map { |outcome| outcome[:detail] } }

      return self.class.failed_state(failed.to_sentence) if delivered.empty?
      return self.class.sent_state(delivered.to_sentence) if failed.empty?

      self.class.sent_state("sent: #{delivered.to_sentence}; failed: #{failed.to_sentence}")
    end
  end
end
All opinions represented herein are my own
- © 2024 - 2026 itsthedevman
- build 43aa0d7