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
neiam
/oh-my-ex-oh-ircd
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
7.5 KiB · text
History
4b6fe3b