defmodule OhMyExOhIrcd.IRC.Channel do
@moduledoc """
Cluster-wide singleton GenServer per channel name.
Owns:
* the authoritative *current* membership including per-member access
level (`:founder`, `:admin`, `:op`, `:halfop`, `:voice`, `:member`);
* the channel topic;
* the persistent founder identity, if the channel is registered
(see `OhMyExOhIrcd.Channels`).
Does NOT own message persistence — that's in the per-node
`MessageLog` CRDT. The Channel writes to the local MessageLog and
reads from PubSub fan-out (so the same path delivers locally-typed
messages and CRDT-replayed messages on heal).
Started lazily by `ensure_started/1`.
"""
use GenServer, restart: :transient
require Logger
alias OhMyExOhIrcd.Cluster.{HordeRegistry, HordeSupervisor}
alias OhMyExOhIrcd.IRC.MessageLog
@pubsub OhMyExOhIrcd.PubSub
@type access :: :founder | :admin | :op | :halfop | :voice | :member
@access_rank %{
founder: 5,
admin: 4,
op: 3,
halfop: 2,
voice: 1,
member: 0
}
# mode letter <-> internal access atom
@mode_letters %{?q => :founder, ?a => :admin, ?o => :op, ?h => :halfop, ?v => :voice}
@doc "Map of mode-letter charcode -> access atom (q/a/o/h/v)."
def mode_letters, do: @mode_letters
@doc "Single-char prefix for an access level (`@`, `+`, …) or empty string for :member."
def prefix(:founder), do: "~"
def prefix(:admin), do: "&"
def prefix(:op), do: "@"
def prefix(:halfop), do: "%"
def prefix(:voice), do: "+"
def prefix(_), do: ""
@doc "Mode letter for an access (`q/a/o/h/v`) or `nil` for :member."
def mode_letter(:founder), do: "q"
def mode_letter(:admin), do: "a"
def mode_letter(:op), do: "o"
def mode_letter(:halfop), do: "h"
def mode_letter(:voice), do: "v"
def mode_letter(_), do: nil
@doc "Numeric rank for an access level — higher means more privileged."
def rank(level), do: Map.get(@access_rank, level, 0)
@doc """
Whether `name` is an acceptable channel name. We require one of the
RFC channel-type prefixes this server routes (`#` or `&`) — i.e. no
prefix-less names — a length of 2–50 including the prefix, and no
spaces, commas, control chars, or `:` in the body. This is the single
source of truth gating channel creation on JOIN (IRC + web).
"""
@spec valid_name?(term()) :: boolean()
def valid_name?(<<prefix, rest::binary>> = name)
when prefix in [?#, ?&] and byte_size(name) <= 50 and rest != "",
do: not String.contains?(rest, [" ", ",", ":", "\0", "\a", "\r", "\n", "\t"])
def valid_name?(_), do: false
## API
def ensure_started(name) do
name = normalize(name)
case HordeRegistry.whereis({:channel, name}) do
pid when is_pid(pid) ->
{:ok, pid}
nil ->
spec = %{
id: {:channel, name},
start: {__MODULE__, :start_link, [name]},
restart: :transient
}
case Horde.DynamicSupervisor.start_child(HordeSupervisor, spec) do
{:ok, pid} -> {:ok, pid}
{:error, {:already_started, pid}} -> {:ok, pid}
other -> other
end
end
end
def start_link(name) do
GenServer.start_link(__MODULE__, name, name: HordeRegistry.via({:channel, name}))
end
@doc """
Member `user_pid` joins as `nick`. `user_id` is the authenticated
account id (or `nil` for anonymous) and is used to detect the channel
founder. `prefix` is the joiner's `nick!user@host` form, used to
check ban / exception / invite-only gates. `key` is the optional
second argument to the IRC JOIN command and must match the channel
key if `+k` is set. Returns `{:error, reason}` if the join is refused
(`:banned`, `:invite_only`, `:bad_key`, `:channel_full`).
"""
def join(name, nick, user_pid, user_id \\ nil, prefix \\ nil, key \\ nil),
do: call(name, {:join, nick, user_pid, user_id, prefix, key})
def part(name, nick, reason \\ ""), do: call(name, {:part, nick, reason})
def privmsg(name, sender, text), do: call(name, {:message, sender, text, :privmsg})
def notice(name, sender, text), do: call(name, {:message, sender, text, :notice})
@doc "Snapshot of members with their access levels, sorted by rank desc then nick."
def members(name), do: call(name, :members)
@doc "Just the nicks (no access info)."
def member_nicks(name), do: call(name, :member_nicks)
@doc "Member info `%{access:, nick:}` or `nil`."
def member_info(name, nick), do: call(name, {:member_info, nick})
def topic(name), do: call(name, :topic)
def topic_info(name), do: call(name, :topic_info)
def set_topic(name, set_by, text), do: call(name, {:set_topic, set_by, text})
@doc """
Apply a heterogeneous list of mode changes:
{:grant, access, target_nick} # +q/+a/+o/+h/+v
{:revoke, access, target_nick} # -q/-a/-o/-h/-v
{:add_mask, :ban | :except | :invite, mask} # +b/+e/+I mask
{:remove_mask, :ban | :except | :invite, mask} # -b/-e/-I mask
{:set_flag, :invite_only | :moderated | :no_external | :flood_exempt | :auto_join}
{:clear_flag, …}
Returns `{:ok, applied}` listing changes that actually took effect,
or `{:error, :not_authorized | :not_on_channel | :no_such_member}`.
"""
def apply_mode_changes(name, granter, changes, opts \\ []),
do: call(name, {:mode_changes, granter, changes, Keyword.get(opts, :operator?, false)})
@doc "List masks of a given type. Returns `[%{mask, set_by, set_at}]`."
def list_masks(name, type) when type in [:ban, :except, :invite],
do: call(name, {:list_masks, type})
@doc "Snapshot the current channel-mode flags as a set of atoms."
def flags(name), do: call(name, :flags)
@doc "Snapshot `{flags, key, limit}` for the channel."
def mode_snapshot(name), do: call(name, :mode_snapshot)
@doc "Record an INVITE for `nick` so they can bypass +i on join."
def record_invite(name, nick), do: call(name, {:record_invite, nick})
@doc """
Tell the running Channel singleton that the channel has just been
registered to `founder_user_id` (called by ChanServ.REGISTER). The
Channel flips its in-memory founder, persists current mode state, and
promotes the founder if they're already in the channel.
"""
def notify_registered(name, founder_user_id),
do: call(name, {:notify_registered, founder_user_id})
@doc "Kick `target` from the channel. `kicker` must be op or higher."
def kick(name, kicker, target, reason),
do: call(name, {:kick, kicker, target, reason})
@doc """
Privileged: set a member's access level directly, bypassing rank
checks. Intended for services (ChanServ) and for tests; broadcasts a
synthetic MODE line attributed to `granter` (defaults to the ChanServ
nick) so subscribers see the change.
"""
def service_set_access(name, nick, access, granter \\ "ChanServ"),
do: call(name, {:service_set_access, nick, access, granter})
@doc false
def __seed_access__(name, nick, access),
do: service_set_access(name, nick, access, "ChanServ")
def topic_topic(name), do: "channel:" <> normalize(name)
@doc """
`%{topic:, member_count:, flags:, key:, limit:}` for a channel that is
running, or `nil` when it isn't. Unlike the other accessors this never
starts the channel — the admin console lists live channels, and
looking one up must not resurrect a channel that was just closed.
"""
@spec live_summary(String.t()) :: map() | nil
def live_summary(name) do
case HordeRegistry.whereis({:channel, normalize(name)}) do
pid when is_pid(pid) -> GenServer.call(pid, :live_summary, 2_000)
nil -> nil
end
catch
:exit, _ -> nil
end
@doc """
Stop the channel's singleton if, and only if, it has no members and is
not registered — for the operator console's "close" control. The check
and the stop happen inside the channel process, so a JOIN can't slip in
between them: joins are serialized through the same mailbox, and one
queued behind this call finds the process gone and starts a fresh
channel (see `call/3`). Leftover persisted state for the channel (its
`channel_topics` row) is deleted before the process exits.
Stopping with `:normal` makes Horde drop the child (it's `:transient`)
exactly as `Horde.DynamicSupervisor.terminate_child/2` would.
Never starts a channel: returns `{:error, :not_running}` when there is
nothing to close. Otherwise `:ok`, `{:error, :in_use}` or
`{:error, :registered}`.
"""
@spec close_if_empty(String.t()) :: :ok | {:error, :in_use | :registered | :not_running}
def close_if_empty(name) do
case HordeRegistry.whereis({:channel, normalize(name)}) do
pid when is_pid(pid) -> GenServer.call(pid, :close_if_empty)
nil -> {:error, :not_running}
end
catch
:exit, _ -> {:error, :not_running}
end
# Calls the pid ensure_started/1 found or started, not the registry name
# again: Horde's registry is synced between nodes as a CRDT, so right
# after a channel starts (or moves) on another node the local copy may
# not have the name yet, and a lookup by name exits with :noproc — which
# took the caller's IRC connection down with it ("JOIN #jamz" on
# 2026-10-27, the first day the pods were clustered). Once more if the
# process went away under the call: a channel being handed to another
# node exits while we hold its old pid.
defp call(name, msg, attempts \\ 2) do
{:ok, pid} = ensure_started(name)
GenServer.call(pid, msg)
catch
:exit, {reason, _} when attempts > 1 and reason in [:noproc, :normal] ->
call(name, msg, attempts - 1)
:exit, {{:shutdown, _}, _} when attempts > 1 ->
call(name, msg, attempts - 1)
end
## Server
@impl true
def init(name) do
Phoenix.PubSub.subscribe(@pubsub, MessageLog.channel_topic(name))
{topic, set_by, set_at, founder_user_id, modes} = load_persisted(name)
base = %{
name: name,
members: %{},
topic: topic,
topic_set_by: set_by,
topic_set_at: set_at,
founder_user_id: founder_user_id,
# Boolean channel modes. We don't track +t explicitly — topic
# auth is rank-based when the channel is registered.
flags: MapSet.new(),
# Downcased nicks that have a pending INVITE (consumed on JOIN).
invited: MapSet.new(),
# `+k` channel key — nil if unset.
key: nil,
# `+l` user limit — nil if unset.
limit: nil,
# Ban / except / invite masks: each a list of `{mask, set_by, set_at}`,
# kept newest-first for predictable RPL_BANLIST ordering.
ban_masks: [],
except_masks: [],
invite_masks: [],
# Cached per-user access loaded from `channel_memberships`: `%{user_id => access}`.
# Anonymous users (no account) aren't in here. Founder is implicit via
# founder_user_id, not stored here.
persisted_access: OhMyExOhIrcd.Channels.load_member_access(name)
}
{:ok, apply_loaded_modes(base, modes)}
end
defp apply_loaded_modes(state, nil), do: state
defp apply_loaded_modes(state, modes) do
%{
state
| key: modes.key,
limit: modes.limit,
flags: modes.flags,
ban_masks: modes.ban_masks,
except_masks: modes.except_masks,
invite_masks: modes.invite_masks
}
end
defp load_persisted(name) do
rec = OhMyExOhIrcd.Channels.get(name)
topic_rec = OhMyExOhIrcd.Channels.get_topic(name)
founder_id = rec && rec.founder_user_id
modes = rec && OhMyExOhIrcd.Channels.load_modes(name)
# Topic source of truth is `channel_topics` (covers unregistered
# channels too). Fall back to the registered channel's legacy
# `channels.topic` for rows written before this table existed.
{topic, set_by, set_at} = persisted_topic(topic_rec, rec, founder_id)
{topic, set_by, set_at, founder_id, modes}
rescue
_ -> {"", "", 0, nil, nil}
end
defp persisted_topic(nil, nil, _founder_id), do: {"", "", 0}
defp persisted_topic(nil, rec, founder_id) do
legacy_set_at =
case rec.updated_at do
%DateTime{} = dt -> DateTime.to_unix(dt)
_ -> 0
end
{rec.topic || "", if(founder_id, do: "ChanServ", else: ""), legacy_set_at}
end
defp persisted_topic(topic_rec, _rec, _founder_id),
do: {topic_rec.topic || "", topic_rec.set_by || "", topic_rec.set_at || 0}
# Same pid already joined: a no-op. A rejoin under a new pid skips the
# entry checks; a fresh join has to pass all of them.
defp join_check(%{pid: pid}, pid, _prefix, _key, _supplied_key, _state), do: :already_joined
defp join_check(nil, _pid, prefix, key, supplied_key, state) do
cond do
prefix != nil and banned?(prefix, state) ->
{:error, :banned}
needs_invite?(state, key) ->
{:error, :invite_only}
state.key != nil and supplied_key != state.key ->
{:error, :bad_key}
state.limit != nil and map_size(state.members) >= state.limit ->
{:error, :channel_full}
true ->
:ok
end
end
defp join_check(_existing, _pid, _prefix, _key, _supplied_key, _state), do: :ok
defp needs_invite?(state, key),
do: MapSet.member?(state.flags, :invite_only) and not MapSet.member?(state.invited, key)
defp admit_member(state, existing, nick, key, pid, user_id, prefix) do
if existing, do: Process.demonitor(existing.ref, [:flush])
ref = Process.monitor(pid)
access = join_access(existing, user_id, state)
{user, host} = parse_prefix(prefix, nick)
member = %{
pid: pid,
ref: ref,
access: access,
nick: nick,
user_id: user_id,
user: user,
host: host
}
members = Map.put(state.members, key, member)
# Consume any pending invite.
invited = MapSet.delete(state.invited, key)
Phoenix.PubSub.broadcast(@pubsub, MessageLog.channel_topic(state.name), {
:irc_presence,
{:join, member_prefix(member), state.name}
})
# Restore the access prefix on rejoin with a synthetic MODE line.
if existing == nil and access not in [:member] do
letter = mode_letter(access) || "v"
Phoenix.PubSub.broadcast(@pubsub, MessageLog.channel_topic(state.name), {
:irc_presence,
{:mode, service_prefix(), state.name, "+" <> letter, [nick]}
})
end
%{state | members: members, invited: invited}
end
defp member_access(_state, nil), do: nil
defp member_access(state, nick) do
case Map.get(state.members, downcase(nick)) do
%{access: a} -> a
_ -> nil
end
end
defp join_access(existing, user_id, state) do
cond do
existing ->
existing.access
user_id != nil and user_id == state.founder_user_id ->
:founder
user_id != nil and Map.has_key?(state.persisted_access, user_id) ->
Map.get(state.persisted_access, user_id)
true ->
:member
end
end
## Persistence helper for per-user prefix access (channel_memberships).
## Updates the cached `persisted_access` map AND writes to the database
## (no-op for unregistered channels and anonymous users).
defp persist_member_access(state, nil, _access), do: state
defp persist_member_access(state, user_id, :member) do
OhMyExOhIrcd.Channels.clear_member_access(state.name, user_id)
%{state | persisted_access: Map.delete(state.persisted_access, user_id)}
end
defp persist_member_access(state, user_id, access) do
# Don't persist founder via this path — it's stored on channels.founder_user_id.
if access == :founder do
state
else
OhMyExOhIrcd.Channels.set_member_access(state.name, user_id, access)
%{state | persisted_access: Map.put(state.persisted_access, user_id, access)}
end
end
## Persistence helper — saves channel-level modes when the channel is
## registered. No-op for unregistered channels.
defp maybe_persist_modes(state) do
if state.founder_user_id do
snapshot = %{
key: state.key,
limit: state.limit,
flags: state.flags,
ban_masks: state.ban_masks,
except_masks: state.except_masks,
invite_masks: state.invite_masks
}
OhMyExOhIrcd.Channels.persist_modes(state.name, snapshot)
end
state
end
@impl true
def handle_call({:join, nick, pid, user_id, prefix, supplied_key}, _from, state) do
key = downcase(nick)
existing = Map.get(state.members, key)
case join_check(existing, pid, prefix, key, supplied_key, state) do
:already_joined ->
{:reply, :ok, state}
{:error, _reason} = err ->
{:reply, err, state}
:ok ->
{:reply, :ok, admit_member(state, existing, nick, key, pid, user_id, prefix)}
end
end
def handle_call({:part, nick, reason}, _from, state) do
key = downcase(nick)
{info, members} = Map.pop(state.members, key)
if info, do: Process.demonitor(info.ref, [:flush])
sender = if info, do: member_prefix(info), else: "#{nick}!#{nick}@unknown"
Phoenix.PubSub.broadcast(@pubsub, MessageLog.channel_topic(state.name), {
:irc_presence,
{:part, sender, state.name, reason}
})
{:reply, :ok, %{state | members: members}}
end
def handle_call({:message, sender, text, kind}, _from, state) do
sender_nick = sender |> String.split("!", parts: 2) |> List.first()
sender_access = member_access(state, sender_nick)
cond do
# +n: only members can speak.
MapSet.member?(state.flags, :no_external) and sender_access == nil ->
{:reply, {:error, :no_external}, state}
# +m: only voice+ can speak.
MapSet.member?(state.flags, :moderated) and
(sender_access == nil or rank(sender_access) < rank(:voice)) ->
{:reply, {:error, :moderated}, state}
true ->
MessageLog.append(state.name, sender, text, kind)
{:reply, :ok, state}
end
end
def handle_call({:list_masks, type}, _from, state) do
list =
case type do
:ban -> state.ban_masks
:except -> state.except_masks
:invite -> state.invite_masks
end
{:reply, list, state}
end
def handle_call(:flags, _from, state), do: {:reply, state.flags, state}
def handle_call(:mode_snapshot, _from, state),
do: {:reply, %{flags: state.flags, key: state.key, limit: state.limit}, state}
def handle_call(:live_summary, _from, state) do
summary = %{
topic: state.topic,
member_count: map_size(state.members),
flags: state.flags,
key: state.key,
limit: state.limit
}
{:reply, summary, state}
end
def handle_call({:record_invite, nick}, _from, state) do
{:reply, :ok, %{state | invited: MapSet.put(state.invited, downcase(nick))}}
end
def handle_call({:notify_registered, founder_user_id}, _from, state) do
state = %{state | founder_user_id: founder_user_id}
# Capture current state so any modes set before registration survive.
state = maybe_persist_modes(state)
{:reply, :ok, state}
end
def handle_call(:close_if_empty, _from, state) do
cond do
map_size(state.members) > 0 ->
{:reply, {:error, :in_use}, state}
state.founder_user_id != nil ->
{:reply, {:error, :registered}, state}
true ->
OhMyExOhIrcd.Channels.delete_unregistered_leftovers(state.name)
{:stop, :normal, :ok, state}
end
end
def handle_call(:members, _from, state) do
list =
state.members
|> Map.values()
|> Enum.sort_by(fn m -> {-rank(m.access), String.downcase(m.nick)} end)
|> Enum.map(fn m ->
%{
nick: m.nick,
access: m.access,
user: Map.get(m, :user, m.nick),
host: Map.get(m, :host, "unknown")
}
end)
{:reply, list, state}
end
def handle_call(:member_nicks, _from, state),
do: {:reply, Enum.map(state.members, fn {_, m} -> m.nick end), state}
def handle_call({:member_info, nick}, _from, state) do
case Map.get(state.members, downcase(nick)) do
nil -> {:reply, nil, state}
m -> {:reply, %{nick: m.nick, access: m.access}, state}
end
end
def handle_call(:topic, _from, state), do: {:reply, state.topic, state}
def handle_call(:topic_info, _from, state),
do:
{:reply, %{text: state.topic, set_by: state.topic_set_by, set_at: state.topic_set_at},
state}
def handle_call({:set_topic, set_by, text}, _from, state) do
# Topic edit requires at least halfop on registered channels; on
# un-registered channels any member can set it (RFC default).
granter_rank =
case Map.get(state.members, downcase(set_by)) do
%{access: a} -> rank(a)
_ -> 0
end
if state.founder_user_id != nil and granter_rank < rank(:halfop) do
{:reply, {:error, :not_authorized}, state}
else
now = System.system_time(:second)
state = %{
state
| topic: text,
topic_set_by: set_by,
topic_set_at: now
}
OhMyExOhIrcd.Channels.put_topic(state.name, set_by, text, now)
Phoenix.PubSub.broadcast(@pubsub, MessageLog.channel_topic(state.name), {
:irc_presence,
{:topic, sender_prefix(state, set_by), state.name, text}
})
{:reply, :ok, state}
end
end
def handle_call({:mode_changes, granter, changes, operator?}, _from, state) do
case Map.get(state.members, downcase(granter)) do
nil ->
{:reply, {:error, :not_on_channel}, state}
%{access: granter_access} ->
do_apply_modes(state, granter, granter_access, changes, operator?)
end
end
def handle_call({:service_set_access, nick, access, granter}, _from, state) do
case Map.get(state.members, downcase(nick)) do
nil ->
{:reply, {:error, :no_such_member}, state}
member ->
members = Map.put(state.members, downcase(nick), %{member | access: access})
state = persist_member_access(%{state | members: members}, member.user_id, access)
letter = mode_letter(access)
sign = if access == :member, do: "-", else: "+"
flag_letter = if access == :member, do: mode_letter(member.access) || "v", else: letter
if flag_letter do
Phoenix.PubSub.broadcast(@pubsub, MessageLog.channel_topic(state.name), {
:irc_presence,
{:mode, sender_prefix(state, granter), state.name, sign <> flag_letter, [member.nick]}
})
end
{:reply, :ok, state}
end
end
def handle_call({:kick, kicker, target, reason}, _from, state) do
case Map.get(state.members, downcase(kicker)) do
nil ->
{:reply, {:error, :not_on_channel}, state}
%{access: kicker_access} = kicker_member ->
case Map.get(state.members, downcase(target)) do
nil ->
{:reply, {:error, :no_such_member}, state}
%{access: target_access, pid: pid, ref: ref, nick: tnick} ->
cond do
rank(kicker_access) < rank(:op) ->
{:reply, {:error, :not_authorized}, state}
kicker_access != :founder and rank(kicker_access) <= rank(target_access) ->
{:reply, {:error, :not_authorized}, state}
true ->
Process.demonitor(ref, [:flush])
members = Map.delete(state.members, downcase(target))
Phoenix.PubSub.broadcast(@pubsub, MessageLog.channel_topic(state.name), {
:irc_presence,
{:kick, member_prefix(kicker_member), state.name, tnick, reason}
})
send(pid, {:irc_kicked, state.name})
{:reply, :ok, %{state | members: members}}
end
end
end
end
defp do_apply_modes(state, granter, granter_access, changes, operator?) do
granter_rank = rank(granter_access)
{state, applied, error} =
Enum.reduce_while(changes, {state, [], nil}, fn change, {st, acc, _err} ->
case apply_one(st, granter, granter_access, granter_rank, operator?, change) do
{:ok, st2, taken} -> {:cont, {st2, [taken | acc], nil}}
{:noop, st2} -> {:cont, {st2, acc, nil}}
{:error, reason} -> {:halt, {st, acc, reason}}
end
end)
case {error, applied} do
{nil, []} -> {:reply, {:ok, []}, state}
{nil, _} -> finalize_mode_changes(state, granter, Enum.reverse(applied))
{reason, _} -> {:reply, {:error, reason}, state}
end
end
# Prefix grants (q/a/o/h/v).
defp apply_one(state, granter, granter_access, granter_rank, _operator?, {op, access, target})
when op in [:grant, :revoke] and is_atom(access) do
case Map.get(state.members, downcase(target)) do
nil ->
{:error, :no_such_member}
%{access: cur_access} = member ->
target_rank = rank(cur_access)
self? = downcase(target) == downcase(granter)
founder? = granter_access == :founder
cond do
founder? -> apply_change(state, member, op, access)
granter_rank < rank(access) -> {:error, :not_authorized}
granter_rank <= target_rank and not self? -> {:error, :not_authorized}
true -> apply_change(state, member, op, access)
end
end
end
# List modes (b/e/I), boolean flags (i/m/n/f), key (+k/-k), and limit
# (+l/-l). All require halfop+ (founder bypass) — *except* two:
# * `+f` (flood_exempt) is founder-only, since it disables a built-in
# protection.
# * `+A` (auto_join) is IRC-operator-only (`operator?` vouched by the
# connection), since it force-joins every connecting user; channel
# rank is irrelevant for it.
defp apply_one(state, granter, granter_access, granter_rank, operator?, change)
when elem(change, 0) in [
:add_mask,
:remove_mask,
:set_flag,
:clear_flag,
:set_key,
:clear_key,
:set_limit,
:clear_limit
] do
cond do
auto_join_change?(change) ->
if operator?,
do: do_apply_non_prefix(state, granter, change),
else: {:error, :not_authorized}
flood_exempt_change?(change) and granter_access != :founder ->
{:error, :not_authorized}
granter_access == :founder or granter_rank >= rank(:halfop) ->
do_apply_non_prefix(state, granter, change)
true ->
{:error, :not_authorized}
end
end
defp flood_exempt_change?({:set_flag, :flood_exempt}), do: true
defp flood_exempt_change?({:clear_flag, :flood_exempt}), do: true
defp flood_exempt_change?(_), do: false
defp auto_join_change?({:set_flag, :auto_join}), do: true
defp auto_join_change?({:clear_flag, :auto_join}), do: true
defp auto_join_change?(_), do: false
defp apply_change(state, member, :grant, access), do: do_grant(state, member, access)
defp apply_change(state, member, :revoke, access), do: do_revoke(state, member, access)
defp do_apply_non_prefix(state, granter, {:add_mask, type, mask}) do
key = mask_field(type)
list = Map.fetch!(state, key)
if Enum.any?(list, fn entry -> entry.mask == mask end) do
{:noop, state}
else
entry = %{
mask: mask,
set_by: mask_setter(granter),
set_at: System.system_time(:second)
}
{:ok, Map.put(state, key, [entry | list]), {:add_mask, type, mask}}
end
end
defp do_apply_non_prefix(state, _granter, {:remove_mask, type, mask}) do
key = mask_field(type)
list = Map.fetch!(state, key)
case Enum.find(list, fn entry -> entry.mask == mask end) do
nil ->
{:noop, state}
_ ->
new_list = Enum.reject(list, fn entry -> entry.mask == mask end)
{:ok, Map.put(state, key, new_list), {:remove_mask, type, mask}}
end
end
defp do_apply_non_prefix(state, _granter, {:set_flag, flag}) do
if MapSet.member?(state.flags, flag) do
{:noop, state}
else
{:ok, %{state | flags: MapSet.put(state.flags, flag)}, {:set_flag, flag}}
end
end
defp do_apply_non_prefix(state, _granter, {:clear_flag, flag}) do
if MapSet.member?(state.flags, flag) do
{:ok, %{state | flags: MapSet.delete(state.flags, flag)}, {:clear_flag, flag}}
else
{:noop, state}
end
end
defp do_apply_non_prefix(state, _granter, {:set_key, key}) do
if state.key == key do
{:noop, state}
else
{:ok, %{state | key: key}, {:set_key, key}}
end
end
# `-k` per RFC takes an argument (the current key) — we accept the supplied
# value to keep parsers happy but don't actually check it.
defp do_apply_non_prefix(state, _granter, {:clear_key, _arg}) do
if state.key == nil do
{:noop, state}
else
{:ok, %{state | key: nil}, {:clear_key, "*"}}
end
end
defp do_apply_non_prefix(state, _granter, {:set_limit, limit})
when is_integer(limit) and limit > 0 do
if state.limit == limit do
{:noop, state}
else
{:ok, %{state | limit: limit}, {:set_limit, limit}}
end
end
defp do_apply_non_prefix(state, _granter, {:set_limit, _bad}), do: {:noop, state}
defp do_apply_non_prefix(state, _granter, {:clear_limit}) do
if state.limit == nil do
{:noop, state}
else
{:ok, %{state | limit: nil}, {:clear_limit}}
end
end
# The `set_by` field in 367/348/346 numerics is conventionally the
# nick of the granter (or `nick!user@host` if you have it). Callers
# pass the bare nick; if anything else turns up we fall back to `*`.
defp mask_setter(nil), do: "*"
defp mask_setter(""), do: "*"
defp mask_setter(s) when is_binary(s), do: s
defp mask_setter(_), do: "*"
defp mask_field(:ban), do: :ban_masks
defp mask_field(:except), do: :except_masks
defp mask_field(:invite), do: :invite_masks
## Ban / exception matching: standard IRC glob (`*` any, `?` one) against
## the joiner's `nick!user@host` prefix.
defp banned?(prefix, state) do
bans = Enum.any?(state.ban_masks, fn %{mask: m} -> match_glob?(prefix, m) end)
excepted? =
Enum.any?(state.except_masks, fn %{mask: m} -> match_glob?(prefix, m) end)
bans and not excepted?
end
defp match_glob?(string, mask) do
pattern =
mask
|> Regex.escape()
|> String.replace("\\*", ".*")
|> String.replace("\\?", ".")
Regex.match?(~r/\A#{pattern}\z/i, string)
end
defp do_grant(state, member, access) do
if member.access == access do
{:noop, state}
else
new = %{member | access: access}
members = Map.put(state.members, downcase(member.nick), new)
state = persist_member_access(%{state | members: members}, member.user_id, access)
{:ok, state, {:grant, access, member.nick}}
end
end
defp do_revoke(state, member, access) do
if member.access == access or rank(member.access) <= rank(access) do
new = %{member | access: :member}
members = Map.put(state.members, downcase(member.nick), new)
state = persist_member_access(%{state | members: members}, member.user_id, :member)
{:ok, state, {:revoke, access, member.nick}}
else
{:noop, state}
end
end
defp finalize_mode_changes(state, granter, applied) do
{flags, targets} = render_modes(applied)
Phoenix.PubSub.broadcast(@pubsub, MessageLog.channel_topic(state.name), {
:irc_presence,
{:mode, sender_prefix(state, granter), state.name, flags, targets}
})
# Persist if any of the applied changes were channel-level (not just
# per-member prefix grants, which are transient).
state =
if Enum.any?(applied, &channel_level_change?/1) do
maybe_persist_modes(state)
else
state
end
{:reply, {:ok, applied}, state}
end
defp channel_level_change?({op, _, _}) when op in [:grant, :revoke], do: false
defp channel_level_change?(_), do: true
defp render_modes(applied) do
Enum.reduce(applied, {"", []}, fn change, {flags, targets} ->
{sign, letter, arg} = render_change(change)
{flags <> sign <> letter, targets ++ List.wrap(arg)}
end)
end
defp render_change({:grant, access, nick}), do: {"+", mode_letter(access), nick}
defp render_change({:revoke, access, nick}), do: {"-", mode_letter(access), nick}
defp render_change({:add_mask, type, mask}), do: {"+", mask_letter(type), mask}
defp render_change({:remove_mask, type, mask}), do: {"-", mask_letter(type), mask}
defp render_change({:set_flag, flag}), do: {"+", flag_letter(flag), nil}
defp render_change({:clear_flag, flag}), do: {"-", flag_letter(flag), nil}
defp render_change({:set_key, key}), do: {"+", "k", key}
defp render_change({:clear_key, arg}), do: {"-", "k", arg}
defp render_change({:set_limit, n}), do: {"+", "l", Integer.to_string(n)}
defp render_change({:clear_limit}), do: {"-", "l", nil}
defp mask_letter(:ban), do: "b"
defp mask_letter(:except), do: "e"
defp mask_letter(:invite), do: "I"
@doc false
def flag_letter(:invite_only), do: "i"
def flag_letter(:moderated), do: "m"
def flag_letter(:no_external), do: "n"
def flag_letter(:flood_exempt), do: "f"
def flag_letter(:auto_join), do: "A"
@doc false
def flag_from_letter(?i), do: :invite_only
def flag_from_letter(?m), do: :moderated
def flag_from_letter(?n), do: :no_external
def flag_from_letter(?f), do: :flood_exempt
def flag_from_letter(?A), do: :auto_join
def flag_from_letter(_), do: nil
@impl true
def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do
{key, member} =
Enum.find(state.members, {nil, nil}, fn
{_k, %{pid: ^pid}} -> true
_ -> false
end)
if member do
Phoenix.PubSub.broadcast(@pubsub, MessageLog.channel_topic(state.name), {
:irc_presence,
{:quit, member_prefix(member), state.name, "Connection closed"}
})
{:noreply, %{state | members: Map.delete(state.members, key)}}
else
{:noreply, state}
end
end
def handle_info({:irc_privmsg, _}, state), do: {:noreply, state}
def handle_info({:irc_presence, _}, state), do: {:noreply, state}
def handle_info(_, state), do: {:noreply, state}
defp normalize(name), do: String.downcase(name)
defp downcase(s) when is_binary(s), do: String.downcase(s)
# Extract `{user, host}` from a `nick!user@host` prefix string. Falls
# back to sensible defaults so callers never end up writing `*` in
# WHO replies (which trips clients like irssi that assert host != nil).
defp parse_prefix(nil, nick), do: {nick, "unknown"}
defp parse_prefix("", nick), do: {nick, "unknown"}
defp parse_prefix(prefix, nick) do
case String.split(prefix, "!", parts: 2) do
[_n, rest] ->
case String.split(rest, "@", parts: 2) do
[u, h] -> {u, h}
[u] -> {u, "unknown"}
end
_ ->
{nick, "unknown"}
end
end
# Full `nick!user@host` prefix for a member. Presence broadcasts
# (JOIN/PART/QUIT/KICK/…) must carry this full form — clients like
# irssi take the host from the message prefix to populate their
# nicklists and assert it's present (`nicklist_set_host: host != NULL`).
defp member_prefix(m),
do: "#{m.nick}!#{Map.get(m, :user) || m.nick}@#{Map.get(m, :host) || "unknown"}"
# Same, but by nick lookup — for senders that may not be plain members
# (services, or a setter that raced a part). Falls back to a synthetic
# but well-formed prefix so the host token is never empty on the wire.
defp sender_prefix(state, nick) do
case Map.get(state.members, downcase(nick)) do
nil ->
if downcase(nick) == downcase(OhMyExOhIrcd.IRC.ChanServ.nick()) do
service_prefix()
else
"#{nick}!#{nick}@unknown"
end
m ->
member_prefix(m)
end
end
defp service_prefix do
"#{OhMyExOhIrcd.IRC.ChanServ.nick()}!service@#{OhMyExOhIrcd.IRC.ChanServ.host()}"
end
end
neiam
/oh-my-ex-oh-ircd
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
35.1 KiB · text
History
4b6fe3b