7.5 KiB · text History 4b6fe3b
defmodule OhMyExOhIrcd.IRC.WebSession do
  @moduledoc """
  In-process IRC "client" for the WebIRC LiveView.

  Mimics enough of `OhMyExOhIrcd.IRC.Connection` to participate as a
  first-class user from the cluster's point of view:

    * registers itself in `HordeRegistry` under `{:user, nick}`, so
      DM PRIVMSGs route to us;
    * subscribes to `MessageLog.channel_topic(channel)` on JOIN so
      channel broadcasts land in our mailbox;
    * answers `:whois_info` so `WHOIS <web_user>` from a TCP client
      works the same as a regular user;
    * forwards every render-worthy event to its owning LiveView pid as
      `{:web_irc, event}` messages.

  The LiveView starts the WebSession via `start_link/1` with itself as
  `owner_pid` and terminates the session on `terminate/2`.
  """

  use GenServer, restart: :transient
  require Logger

  alias OhMyExOhIrcd.Cluster.HordeRegistry
  alias OhMyExOhIrcd.IRC.{Channel, MessageLog}

  @pubsub OhMyExOhIrcd.PubSub

  ## Public API

  def start_link(opts) do
    nick = Keyword.fetch!(opts, :nick)
    owner_pid = Keyword.fetch!(opts, :owner_pid)
    user = Keyword.get(opts, :user)
    GenServer.start_link(__MODULE__, %{nick: nick, owner: owner_pid, user: user})
  end

  def join(pid, channel), do: GenServer.call(pid, {:join, channel})
  def part(pid, channel, reason \\ ""), do: GenServer.call(pid, {:part, channel, reason})
  def privmsg(pid, target, text), do: GenServer.call(pid, {:privmsg, target, text})
  def quit(pid), do: GenServer.cast(pid, :quit)

  @doc "Snapshot of joined channels + their member nicks."
  def state(pid), do: GenServer.call(pid, :state)

  ## Server

  @impl true
  def init(%{nick: nick, owner: owner, user: user}) do
    # Register in Horde with the user-id so cross-system features
    # (founder auto-promote etc.) see us as an authenticated user.
    case Horde.Registry.register(
           HordeRegistry,
           {:user, String.downcase(nick)},
           %{node: node(), user_id: user && user.id, web?: true}
         ) do
      {:ok, _} ->
        Process.monitor(owner)

        {:ok,
         %{
           nick: nick,
           user: user,
           owner: owner,
           channels: MapSet.new(),
           signon: System.system_time(:second),
           last_activity: System.system_time(:second)
         }}

      {:error, _} ->
        {:stop, :nick_in_use}
    end
  end

  @impl true
  def handle_call({:join, channel}, _from, state) do
    user_id = state.user && state.user.id
    prefix = "#{state.nick}!#{state.nick}@web"

    if Channel.valid_name?(channel) do
      case Channel.ensure_started(channel) do
        {:ok, _} ->
          Phoenix.PubSub.subscribe(@pubsub, MessageLog.channel_topic(channel))

          case Channel.join(channel, state.nick, self(), user_id, prefix, nil) do
            :ok ->
              backlog = MessageLog.entries(channel)
              notify(state, {:joined, channel, backlog, Channel.members(channel)})

              {:reply, :ok,
               %{state | channels: MapSet.put(state.channels, String.downcase(channel))}}

            {:error, reason} = err ->
              Phoenix.PubSub.unsubscribe(@pubsub, MessageLog.channel_topic(channel))
              notify(state, {:join_refused, channel, reason})
              {:reply, err, state}
          end

        err ->
          {:reply, err, state}
      end
    else
      notify(state, {:join_refused, channel, :bad_channel_name})
      {:reply, {:error, :bad_channel_name}, state}
    end
  end

  def handle_call({:part, channel, reason}, _from, state) do
    if MapSet.member?(state.channels, String.downcase(channel)) do
      Channel.part(channel, state.nick, reason)
      Phoenix.PubSub.unsubscribe(@pubsub, MessageLog.channel_topic(channel))
      notify(state, {:parted, channel})

      {:reply, :ok, %{state | channels: MapSet.delete(state.channels, String.downcase(channel))}}
    else
      {:reply, {:error, :not_on_channel}, state}
    end
  end

  def handle_call({:privmsg, target, text}, _from, state) do
    sender = "#{state.nick}!#{state.nick}@web"

    if channel_target?(target) do
      if MapSet.member?(state.channels, String.downcase(target)) do
        case Channel.privmsg(target, sender, text) do
          :ok -> {:reply, :ok, %{state | last_activity: System.system_time(:second)}}
          {:error, _} = err -> {:reply, err, state}
        end
      else
        {:reply, {:error, :not_on_channel}, state}
      end
    else
      case HordeRegistry.whereis({:user, String.downcase(target)}) do
        pid when is_pid(pid) ->
          send(pid, {
            :irc_privmsg,
            %{sender: sender, target: target, text: text, kind: :privmsg}
          })

          {:reply, :ok, %{state | last_activity: System.system_time(:second)}}

        nil ->
          {:reply, {:error, :no_such_user}, state}
      end
    end
  end

  # Cross-node WHOIS query — defined alongside the other handle_call
  # clauses so the compiler doesn't complain about scattered groupings.
  def handle_call(:whois_info, _from, state) do
    info = %{
      nick: state.nick,
      user: state.nick,
      host: "web",
      realname: state.nick,
      channels: MapSet.to_list(state.channels),
      idle: max(System.system_time(:second) - state.last_activity, 0),
      signon: state.signon,
      away: nil,
      account: state.user && state.user.email,
      operator?: false
    }

    {:reply, info, state}
  end

  def handle_call(:state, _from, state) do
    channels =
      state.channels
      |> Enum.map(fn key ->
        # `key` is the downcased name; refetch members via that.
        members =
          try do
            Channel.members(key)
          catch
            _, _ -> []
          end

        %{name: key, members: members}
      end)

    {:reply, %{nick: state.nick, channels: channels, signon: state.signon}, state}
  end

  @impl true
  def handle_cast(:quit, state) do
    {:stop, :normal, state}
  end

  ## Inbound presence + messages routed by PubSub or direct send

  @impl true
  def handle_info({:irc_privmsg, entry}, state) do
    notify(state, {:irc_privmsg, entry})
    {:noreply, state}
  end

  def handle_info({:irc_presence, event}, state) do
    notify(state, {:irc_presence, event})
    {:noreply, state}
  end

  def handle_info({:irc_kicked, channel}, state) do
    Phoenix.PubSub.unsubscribe(@pubsub, MessageLog.channel_topic(channel))
    notify(state, {:kicked, channel})
    {:noreply, %{state | channels: MapSet.delete(state.channels, String.downcase(channel))}}
  end

  def handle_info({:irc_invite, inviter, channel}, state) do
    notify(state, {:invited, inviter, channel})
    {:noreply, state}
  end

  def handle_info({:irc_killed, killer, reason}, state) do
    notify(state, {:killed, killer, reason})
    {:stop, :normal, state}
  end

  # The LiveView owner went away — we go too.
  def handle_info({:DOWN, _ref, :process, pid, _}, %{owner: pid} = state),
    do: {:stop, :normal, state}

  def handle_info(_, state), do: {:noreply, state}

  @impl true
  def terminate(_reason, state) do
    # Leave every joined channel so other members see a clean QUIT.
    Enum.each(state.channels, fn key -> Channel.part(key, state.nick, "WebIRC disconnect") end)

    case HordeRegistry.whereis({:user, String.downcase(state.nick)}) do
      pid when pid == self() ->
        Horde.Registry.unregister(HordeRegistry, {:user, String.downcase(state.nick)})

      _ ->
        :ok
    end

    :ok
  end

  ## Helpers

  defp notify(%{owner: pid}, event), do: send(pid, {:web_irc, event})

  defp channel_target?("#" <> _), do: true
  defp channel_target?("&" <> _), do: true
  defp channel_target?(_), do: false
end