defmodule Slack.Bot do @moduledoc ~S""" ## Options - `token` (required), the bot Slack token. """ ### Macro Details ### @callback init(state :: any) :: {:ok, state :: any} | {:error, atom} @callback handle_info( message :: term, conn :: Slack.Bot.Conn.t(), state :: any ) :: :ok | {:ok, new_state :: any} @callback handle_event( event :: map, conn :: Slack.Bot.Conn.t(), state :: any ) :: :ok | {:ok, new_state :: any} @doc @moduledoc defmacro __using__(opts \\ []) do complete_opts = if otp = opts[:otp_app] do quote do Keyword.merge( unquote(opts), Application.get_env(unquote(otp), __MODULE__, []) ) end else quote do unquote(opts) end end quote do @behaviour unquote(__MODULE__) @doc unquote(@moduledoc) @spec child_spec(Keyword.t()) :: tuple def child_spec(opts \\ []) do opts = Keyword.merge(unquote(complete_opts), opts) %{ id: opts[:name] || __MODULE__, start: {unquote(__MODULE__), :start_link, [[{:client, __MODULE__} | opts]]} } end @doc unquote(@moduledoc) @spec start_link(Keyword.t()) :: tuple def start_link(opts \\ []) do opts = Keyword.merge(unquote(complete_opts), opts) unquote(__MODULE__).start_link([{:client, __MODULE__} | opts]) end @impl unquote(__MODULE__) def init(state), do: {:ok, state} @impl unquote(__MODULE__) def handle_info(_msg, _conn, state), do: {:ok, state} defoverridable init: 1, handle_info: 3 # Fix defoverridable spec @spec handle_info( message :: term, conn :: Slack.Bot.Conn.t(), state :: any ) :: :ok | {:ok, new_state :: any} end end ### Bot Implementation ### alias __MODULE__.{Conn, Credentials} require Logger def start_link(opts \\ []) do token = opts[:token] with client when is_atom(client) <- opts[:client] || {:error, :missing_client}, {:ok, cred} <- connect(token), {:ok, client_state} <- client.init(opts[:state]) do Logger.debug(fn -> "Slacks: Connecting as #{cred.self.name}." end) :websocket_client.start_link(String.to_charlist(cred.url), __MODULE__, %Conn{ token: token, self: cred.self, client: client, client_state: client_state }) end end @doc ~S""" Connect to the API rtm with the given token. Returns bot credentials. """ @spec connect(String.t()) :: {:ok, Credentials.t()} | {:error, atom} def connect(token) do url = "https://slack.com/api/rtm.connect?token=#{token}&batch_presence_aware=true&presence_sub=true" case HTTPX.get(url, format: :json_atoms) do {:ok, %{body: token = %{ok: true}}} -> Credentials.parse(token) {:ok, %{body: %{ok: false}}} -> {:error, :invalid_token} error -> error end end @behaviour :websocket_client @impl :websocket_client def init(state), do: {:reconnect, state} @impl :websocket_client def onconnect(_websocket_request, state) do {:ok, state} end @impl :websocket_client def websocket_handle({:ping, ping}, _conn, state) do {:reply, {:pong, ping}, state} end def websocket_handle({:text, ~S({"type": "hello"})}, _conn, state = %{self: self}) do Logger.info(fn -> "Slacks: #{self.name} connected." end) {:ok, state} end def websocket_handle({:text, data}, _conn, state) do case Jason.decode(data, keys: :atoms) do {:ok, event = %{type: _type}} -> Logger.debug(fn -> "Slacks: Event: #{inspect(event)}" end) case state.client.handle_event(event, state, state.client_state) do :ok -> {:ok, state} {:ok, new_state} -> {:ok, %{state | client_state: new_state}} end {:ok, data} -> Logger.warn(fn -> "Slacks: Unhandled payload: #{inspect(data)}" end) {:ok, state} {:error, error} -> Logger.warn(fn -> "Slacks: Error parsing message: #{inspect(error)}" end) {:ok, state} end end def websocket_handle(msg, _conn, state) do Logger.warn(fn -> "Slacks: Unhandled message: #{inspect(msg)}" end) {:ok, state} end @impl :websocket_client def ondisconnect(_, state = %{client: client}) do Logger.warn(fn -> "Slacks: #{client} disconnected, reconnecting..." end) {:reconnect, state} end @impl :websocket_client def websocket_info(message, _conn, state) do case state.client.handle_info(message, state, state.client_state) do :ok -> {:ok, state} {:ok, new_state} -> {:ok, %{state | client_state: new_state}} end end @impl :websocket_client def websocket_terminate(reason, _conn, %{client: client}) do Logger.debug(fn -> "Slacks: Terminating #{client} (#{reason})" end) :ok end end