Friday, September 20, 2024 12:13:53 AM
> settings

Customize


Authenticate

> bot.rb
# frozen_string_literal: true

module ESM
  class Bot < Discordrb::Bot
    # View Channels
    # Send Messages
    # Embed Links
    # Attach Files
    # Add Reactions
    PERMISSION_BITS = 52_288

    STATUS_TYPES = {
      PLAYING: 0,
      STREAMING: 1,
      LISTENING: 2,
      WATCHING: 3
    }.freeze

    INTENTS = Discordrb::INTENTS.slice(
      :servers,
      :server_members,
      # :server_bans,
      # :server_emojis,
      # :server_integrations,
      # :server_webhooks,
      # :server_invites,
      # :server_voice_states,
      # :server_presences,
      :server_messages,
      # :server_message_reactions,
      :server_message_typing,
      :direct_messages,
      # :direct_message_reactions,
      :direct_message_typing
    ).keys.freeze

    attr_reader :config, :metadata, :delivery_overseer

    def initialize
      @timer = Timer.new
      @waiting_for = {}
      @mutex = Mutex.new
      @delivery_overseer = ESM::Bot::DeliveryOverseer.new

      super(token: ESM.config.token, intents: INTENTS)
    end

    def run(async: false, skip_initialization: false)
      @timer.start!

      ESM::Command.load

      # This is needed for console. Otherwise the console's connection would start receiving events
      # such as server connections
      if skip_initialization
        warn!("skip_initialization is set! ESM will not connect to Discord, bind to any events, or start any services")
        return
      end

      # Binds the Discord Events
      bind_events!

      # Start the bot
      super(async)
    end

    def stop
      return if stopping?

      @esm_status = :stopping
      ESM::API.stop!
      ESM::Websocket::Server.stop
      ESM.connection_server.stop
      ESM::Request::Overseer.die

      super
    end

    ###########################
    # Discord Events!
    # These all have to have unique-to-ESM names since we are inheriting
    ###########################
    def bind_events!
      mention(&method(:esm_mention))
      ready(&method(:esm_ready))
      server_create(&method(:esm_server_create))
      user_ban(&method(:esm_user_ban))
      user_unban(&method(:esm_user_unban))
      member_join(&method(:esm_member_join))
    end

    def esm_mention(_event)
      # This event is raised when the bot is mentioned in a message.
    end

    def esm_ready(_event)
      @metadata = ESM::BotAttribute.first_or_create

      if metadata.present?
        update_status(
          # status
          "online",
          # activity
          metadata.status_message,
          # url
          nil,
          # since: 0
          # afk: 0
          activity_type: STATUS_TYPES[metadata.status_type]
        )
      end

      # Sometimes the bot loses connection with Discord. Upon reconnect, the ready event will be triggered again.
      # Don't restart the websocket server again.
      if ready?
        warn!("ESM::Bot#esm_ready called after bot was ready")
        return
      end

      # Once everything is set up, the commands can be hooked
      ESM::Command.setup_event_hooks!

      @esm_status = :ready

      info!(
        status: @esm_status,
        invite_url: invite_url(permission_bits: ESM::Bot::PERMISSION_BITS),
        started_in: "#{@timer.stop!}s"
      )

      ESM::API.run!

      # V1
      ESM::Websocket.start!
      ESM::Request::Overseer.watch
      # V1

      # Wait until after the bot is connected before allowing servers to connect
      ESM.connection_server.start
    end

    def esm_server_create(event)
      # This event is raised when a server is created respective to the bot
      ESM::Event::ServerCreate.new(event.server).run!
    end

    def esm_user_ban(event)
      # This event is raised when a user is banned from a server.
      # IDEA: Ask the banning admin if they would also like to ban the user on the server
    end

    def esm_user_unban(event)
      # This event is raised when a user is unbanned from a server.
    end

    # Fires when a member joins a Discord server
    def esm_member_join(event)
      return if ESM.env.development? && ESM.config.dev_user_allowlist.include?(event.user.id.to_s)

      ESM::Event::MemberJoin.new(event).run!
    end

    ###########################
    # Public Methods
    ###########################

    #
    # Checks if the bot has send permission to the provided channel
    #
    # @param permission [Symbol] The permission to check
    # @param channel [Discordrb::Channel] The channel to check
    #
    # @return [Boolean]
    #
    def channel_permission?(permission, channel)
      profile.on(channel.server)&.permission?(permission, channel) || false
    end

    def stopping?
      @esm_status == :stopping
    end

    def ready?
      @esm_status == :ready
    end

    #
    # Sends a message via the bot to a channel
    #
    # @param message [String, ESM::Embed] A message or embed to send
    # @param to [String, Discordrb::Commands::CommandEvent, Discordrb::Channel, Discordrb::Member, Discordrb::User] Where should the message be sent? This ultimately will end up as a channel
    # @param embed_message [String] An optional message to attach with an embed. Only works if `message` is an embed
    # @param replying_to [Discordrb::Message] A message to "reply" to. Discord will reference the previous message
    # @param async [true/false] Controls if this should block and wait for the message to send and discord to reply, or send it async and immediately return
    #
    # @return [Discordrb::Message, nil] Nil if async: true or if there was an error.
    #
    def deliver(message, to:, embed_message: "", replying_to: nil, async: true)
      return if message.blank?

      replying_to = nil if replying_to.present? && !replying_to.is_a?(Discordrb::Message)
      message = message.join("\n") if message.is_a?(Array)

      delivery_channel = determine_delivery_channel(to)
      raise ESM::Exception::ChannelNotFound.new(message, to) if delivery_channel.nil?

      if !delivery_channel.pm?
        raise ESM::Exception::ChannelAccessDenied if !channel_permission?(:read_messages, delivery_channel)
        raise ESM::Exception::ChannelAccessDenied if !channel_permission?(:send_messages, delivery_channel)
      end

      @delivery_overseer.add(
        message,
        delivery_channel,
        embed_message: embed_message,
        replying_to: replying_to,
        wait: !async
      )
    rescue ESM::Exception::ChannelAccessDenied
      embed = ESM::Embed.build(
        :error,
        description: I18n.t("exceptions.deliver_failure", channel_name: delivery_channel.name, message: message)
      )

      community = ESM::Community.find_by_guild_id(delivery_channel.server.id)
      community.log_event(:error, embed)

      nil
    rescue => e
      warn!(error: e)

      nil
    end

    def __deliver(message, delivery_channel, embed_message: "", replying_to: nil)
      info!(
        channel: "#{delivery_channel.name} (#{delivery_channel.id})",
        message: message.is_a?(ESM::Embed) ? message.to_h : message
      )

      if message.is_a?(ESM::Embed)
        # Send the embed
        delivery_channel.send_embed(embed_message, nil, nil, false, nil, replying_to) { |embed| message.transfer(embed) }
      else
        # Send the text message
        delivery_channel.send_message(message, false, nil, nil, nil, replying_to)
      end
    rescue => e
      warn!(error: e)

      nil
    end

    # Also see #wait_for_reply
    def await_response(responding_user, expected:, timeout: nil)
      counter = 0
      match = nil
      invalid_response = format_invalid_response(expected)

      responding_user =
        if responding_user.is_a?(String)
          user(responding_user)
        elsif responding_user.is_a?(ESM::User)
          responding_user.discord_user
        else
          responding_user
        end

      while match.nil? && counter < 99
        response =
          if ESM.env.test?
            ESM::Test.wait_for_response(timeout: timeout)
          else
            # Add the await event
            responding_user.await!(timeout: timeout)
          end

        # We timed out
        break if response.nil?

        # Parse the match from the event
        match = response.message.content.match(
          Regexp.new(
            "(#{expected.map(&:downcase).join("|")})",
            Regexp::IGNORECASE
          )
        )

        # We found what we were looking for
        break if !match.nil?

        # Let the user know that was not quite what we were looking for
        deliver(invalid_response, to: responding_user)

        counter += 1
      end

      raise ESM::Exception::CheckFailure, I18n.t("failure_to_communicate") if match.nil?

      # Return the match
      match[1]
    end

    # Channel can be any of the following: An CommandEvent, a Channel, a User/Member, or a String (Channel, or user)
    def determine_delivery_channel(channel)
      return if channel.nil?

      case channel
      when Discordrb::Commands::CommandEvent
        channel.channel
      when Discordrb::Channel
        channel
      when ESM::User
        channel.discord_user.pm
      when Discordrb::Member, Discordrb::User
        channel.pm
      when String, Numeric
        # Try checking if it's a text channel
        temp_channel = self.channel(channel)

        # Okay, it might be a PM channel, just go with it regardless (it returns nil)
        if temp_channel.nil?
          pm_channel(channel)
        else
          temp_channel
        end
      end
    end

    #
    # Successor to #await_response. Waits for an event from user_id and channel_id
    #
    # @param user_id [Integer/String] The ID of the user who sends the message
    # @param channel_id [Integer/String] The ID of the channel where the message is sent to. The bot must be a member of said channel
    # @param expires_at [DateTime, Time] When this time is reached, the callback will be called with `nil` for the event
    # @param &callback [Proc] The code to execute once the message has been received
    #
    # @return [true]
    #
    def wait_for_reply(user_id:, channel_id:, expires_at: 5.minutes.from_now, &callback)
      @mutex.synchronize do
        @waiting_for[user_id] ||= []
        @waiting_for[user_id] << channel_id
      end

      # Event will be nil if it times out
      timeout = expires_at - ::Time.zone.now
      event =
        if ESM.env.test?
          ESM::Test.wait_for_response(timeout: timeout)
        else
          add_await!(Discordrb::Events::MessageEvent, {from: user_id, in: channel_id, timeout: timeout})
        end

      @mutex.synchronize do
        @waiting_for[user_id]&.delete_if { |id| id == channel_id }
      end

      return event unless callback

      yield(event)
      nil
    end

    def waiting_for_reply?(user_id:, channel_id:)
      @mutex.synchronize do
        channel_ids = @waiting_for[user_id]
        return false if channel_ids.blank?

        channel_ids.include?(channel_id)
      end
    end

    def log_error(error_hash)
      error!(error_hash)
      return if ESM.config.error_logging_channel_id.blank?

      error_message = JSON.pretty_generate(error_hash).tr("`", "\\\\`")
      ESM.bot.deliver("```#{error_message}```", to: ESM.config.error_logging_channel_id)
    end

    private

    def format_invalid_response(expected)
      expected_string = expected.map { |value| "`#{value}`" }.join(" or ")
      I18n.t("invalid_response", expected: expected_string)
    end
  end
end
All opinions represented herein are my own
- © 2024 itsthedevman
- build 340fbb8