lib/tmi/connection_server.ex

defmodule TMI.ConnectionServer do
  @moduledoc """
  Handles connections to Twitch chat.
  """
  use GenServer

  require Logger

  alias TMI.ChannelServer
  alias TMI.Client
  alias TMI.Conn

  @tmi_capabilities ['membership', 'tags', 'commands']

  @hibernate_after_ms 20_000

  # ----------------------------------------------------------------------------
  # Public API
  # ----------------------------------------------------------------------------

  @doc """
  Start the connection handler.
  """
  @spec start_link({module(), Conn.t()}) :: GenServer.on_start()
  def start_link({bot, conn}) do
    GenServer.start_link(__MODULE__, {bot, conn},
      name: module_name(bot),
      hibernate_after: @hibernate_after_ms
    )
  end

  @doc """
  Get the bot-specific module name.
  """
  @spec module_name(module()) :: module()
  def module_name(bot) do
    Module.concat([bot, "ConnectionServer"])
  end

  # ----------------------------------------------------------------------------
  # GenServer callbacks
  # ----------------------------------------------------------------------------

  @doc """
  Invoked when the server is started. `start_link/3` will block until it
  returns.
  """
  @impl GenServer
  def init({bot, conn}) do
    Client.add_handler(conn, self())
    {:ok, %{bot: bot, conn: conn}, {:continue, :connect}}
  end

  @doc """
  Invoked to handle `continue` instructions.

  It is useful for performing work after initialization or for splitting the
  work in a callback in multiple steps, updating the process state along the
  way.
  """
  @impl GenServer
  def handle_continue(:connect, state) do
    connect(state.conn)
    {:noreply, state}
  end

  @doc """
  Invoked to handle all other messages.

  For example calling `Process.send_after(self(), :foo, 1000)` would send `:foo`
  after one second, and we could match on that here.
  """
  @impl GenServer
  def handle_info(:connect, state) do
    unless Client.is_connected?(state.conn.client) do
      connect(state.conn)
    end

    {:noreply, state}
  end

  def handle_info({:connected, _server, _port}, %{conn: conn} = state) do
    case Client.logon(conn) do
      :ok ->
        Logger.info("[TMI.ConnectionServer] LOGGED IN as #{conn.user}")

      {:error, :not_connected} ->
        Logger.error("[TMI.ConnectionServer] Cannot LOG IN, not connected")
    end

    {:noreply, state}
  end

  def handle_info(:logged_in, %{conn: conn} = state) do
    Logger.debug("[TMI] Logged in to #{conn.server}:#{conn.port}")
    Enum.each(conn.caps, &request_capabilities(conn, &1))
    Enum.each(conn.channels, &join_channel(state.bot, &1))
    {:noreply, state}
  end

  def handle_info(:disconnected, %{conn: conn} = state) do
    Logger.info("[TMI.ConnectionServer] Disconnected from #{conn.server}:#{conn.port}")
    {:stop, :normal, state}
  end

  def handle_info({:disconnected, "@" <> _cmd, _msg}, %{conn: conn} = state) do
    Logger.info("[TMI.ConnectionServer] Disconnected from #{conn.server}:#{conn.port}")
    {:noreply, state}
  end

  def handle_info({:notice, msg, _sender}, state) do
    Logger.error("[TMI.ConnectionServer] NOTICE: #{msg}")
    {:noreply, state}
  end

  # Catch-all for unhandled.
  def handle_info(_msg, state) do
    {:noreply, state}
  end

  # Quit the channels and close the underlying client connection when the
  # process is terminating.
  @impl GenServer
  def terminate(_, %{conn: conn}) do
    Logger.warn("[TMI.ConnectionServer] Terminating...")
    Client.quit(conn, "[TMI.ConnectionServer] Goodbye, cruel world.")
    Client.stop(conn)
  end

  # ----------------------------------------------------------------------------
  # Internal API
  # ----------------------------------------------------------------------------

  defp connect(%Conn{} = conn) do
    case Client.connect_ssl(conn) do
      :ok ->
        Logger.info("[TMI.ConnectionServer] Connected to #{conn.server}:#{conn.port}...")
        :ok

      {:error, reason} ->
        Logger.error("[TMI.ConnectionServer] Unable to connect: #{inspect(reason)}")
        {:error, reason}
    end
  end

  defp request_capabilities(conn, cap) when cap in @tmi_capabilities do
    Logger.info("[TMI.ConnectionServer] Requesting #{cap} capability...")
    Client.command(conn, ['CAP REQ :twitch.tv/', cap])
  end

  # If you know what you're doing, you can request other capabilities ¯\_(ツ)_/¯
  defp request_capabilities(conn, cap) do
    Logger.warn("[TMI.ConnectionServer] Requesting NON-TMI capability: #{cap}...")
    Client.command(conn, to_charlist(cap))
  end

  defp join_channel(bot, channel) do
    Logger.debug("[TMI.ConnectionServer] Joining channel #{channel}...")
    ChannelServer.join(bot, channel)
  end
end