Skip to main content

lib/slack/bot.ex

defmodule Slack.Bot do
  @moduledoc """
  This module is used to spawn bots and is used to manage the connection to Slack
  while delegating events to the specified bot module.
  """

  require Logger

  @behaviour :websocket_client

  @doc """
  Connects to Slack and delegates events to `bot_handler`.

  ## Options

  * `keepalive` - How long to wait for the connection to respond before the client kills the connection.
  * `name` - registers a name for the process with the given atom

  ## Examples

      {:ok, pid} = Slack.Bot.start_link(MyBot, [1,2,3], "abc-123", %{name: :slack_bot})

      :sys.get_state(:slack_bot)

  """
  def start_link(bot_handler, initial_state, token, options \\ %{}) do
    options =
      Map.merge(
        %{
          client: :websocket_client,
          keepalive: 10_000,
          name: nil
        },
        Map.new(options)
      )

    rtm_module = Application.get_env(:slack, :rtm_module, Slack.Rtm)

    case rtm_module.start(token) do
      {:ok, rtm} ->
        state = %{
          bot_handler: bot_handler,
          rtm: rtm,
          client: options.client,
          token: token,
          initial_state: initial_state
        }

        url = String.to_charlist(state.rtm.url)

        {:ok, pid} =
          options.client.start_link(url, __MODULE__, state, keepalive: options.keepalive)

        if options.name != nil do
          Process.register(pid, options.name)
        end

        {:ok, pid}

      {:error, %HTTPoison.Error{reason: :connect_timeout}} ->
        {:error, "Timed out while connecting to the Slack RTM API"}

      {:error, %HTTPoison.Error{reason: :nxdomain}} ->
        {:error, "Could not connect to the Slack RTM API"}

      {:error, %Slack.JsonDecodeError{string: "You are sending too many requests. Please relax."}} ->
        {:error, "Sent too many connection requests at once to the Slack RTM API."}

      {:error, error} ->
        {:error, error}
    end
  end

  @doc false
  @deprecated """
  `rtm.start` is replaced with `rtm.connect` and will no longer receive bots, channels, groups, users, or IMs.
  In future versions these will no longer be provided on initialization.
  """
  def init(%{
        bot_handler: bot_handler,
        rtm: rtm,
        client: client,
        token: token,
        initial_state: initial_state
      }) do
    slack = %Slack.State{
      process: self(),
      client: client,
      token: token,
      me: rtm.self,
      team: rtm.team
    }

    {:reconnect, %{slack: slack, bot_handler: bot_handler, process_state: initial_state}}
  end

  @doc false
  def onconnect(
        _websocket_request,
        %{slack: slack, process_state: process_state, bot_handler: bot_handler} = state
      ) do
    {:ok, new_process_state} = bot_handler.handle_connect(slack, process_state)
    {:ok, %{state | process_state: new_process_state}}
  end

  @doc false
  def ondisconnect({:error, :keepalive_timeout}, state) do
    {:reconnect, state}
  end

  def ondisconnect(
        reason,
        %{slack: slack, process_state: process_state, bot_handler: bot_handler} = state
      ) do
    try do
      bot_handler.handle_close(reason, slack, process_state)
      {:close, reason, state}
    rescue
      e ->
        Logger.error(__STACKTRACE__)
        handle_exception(e)
        {:close, reason, state}
    end
  end

  @doc false
  def websocket_info(
        message,
        _connection,
        %{slack: slack, process_state: process_state, bot_handler: bot_handler} = state
      ) do
    try do
      {:ok, new_process_state} = bot_handler.handle_info(message, slack, process_state)
      {:ok, %{state | process_state: new_process_state}}
    rescue
      e ->
        Logger.error(__STACKTRACE__)
        handle_exception(e)
        {:ok, state}
    end
  end

  @doc false
  def websocket_terminate(_reason, _conn, _state), do: :ok

  @doc false
  def websocket_handle(
        {:text, message},
        _conn,
        %{slack: slack, process_state: process_state, bot_handler: bot_handler} = state
      ) do
    message = prepare_message(message)

    updated_slack =
      if Map.has_key?(message, :type) do
        Slack.State.update(message, slack)
      else
        slack
      end

    new_process_state =
      if Map.has_key?(message, :type) do
        try do
          {:ok, new_process_state} = bot_handler.handle_event(message, slack, process_state)
          new_process_state
        rescue
          e ->
            Logger.error(__STACKTRACE__)
            handle_exception(e)
        end
      else
        process_state
      end

    {:ok, %{state | slack: updated_slack, process_state: new_process_state}}
  end

  def websocket_handle(_, _conn, state), do: {:ok, state}

  defp prepare_message(binstring) do
    binstring
    |> :binary.split(<<0>>)
    |> List.first()
    |> Jason.decode!(keys: :atoms)
  end

  defp handle_exception(e) do
    message = Exception.message(e)
    Logger.error(message)
    raise message
  end
end