diff options
author | Jordan Bracco <href@random.sh> | 2022-12-20 02:19:42 +0000 |
---|---|---|
committer | Jordan Bracco <href@random.sh> | 2022-12-20 19:29:41 +0100 |
commit | 9958e90eb5eb5a2cc171c40860745e95a96bd429 (patch) | |
tree | b49cdb1d0041b9c0a81a14950d38c0203896f527 /lib/irc | |
parent | Rename to Nola (diff) |
Actually do not prefix folders with nola_ refs T77
Diffstat (limited to 'lib/irc')
-rw-r--r-- | lib/irc/account.ex | 263 | ||||
-rw-r--r-- | lib/irc/admin_handler.ex | 41 | ||||
-rw-r--r-- | lib/irc/connection.ex | 521 | ||||
-rw-r--r-- | lib/irc/conns.ex | 3 | ||||
-rw-r--r-- | lib/irc/irc.ex | 79 | ||||
-rw-r--r-- | lib/irc/membership.ex | 129 | ||||
-rw-r--r-- | lib/irc/nola_irc.ex | 34 | ||||
-rw-r--r-- | lib/irc/puppet_connection.ex | 238 | ||||
-rw-r--r-- | lib/irc/user_track.ex | 329 |
9 files changed, 1637 insertions, 0 deletions
diff --git a/lib/irc/account.ex b/lib/irc/account.ex new file mode 100644 index 0000000..a39733c --- /dev/null +++ b/lib/irc/account.ex @@ -0,0 +1,263 @@ +defmodule IRC.Account do + alias IRC.UserTrack.User + + @moduledoc """ + Account registry.... + + Maps a network predicate: + * `{net, {:nick, nickname}}` + * `{net, {:account, account}}` + * `{net, {:mask, user@host}}` + to an unique identifier, that can be shared over multiple networks. + + If a predicate cannot be found for an existing account, a new account will be made in the database. + + To link two existing accounts from different network onto a different one, a merge operation is provided. + + """ + + # FIXME: Ensure uniqueness of name? + + @derive {Poison.Encoder, except: [:token]} + defstruct [:id, :name, :token] + @type t :: %__MODULE__{id: id(), name: String.t()} + @type id :: String.t() + + defimpl Inspect, for: __MODULE__ do + import Inspect.Algebra + + def inspect(%{id: id, name: name}, opts) do + concat(["#IRC.Account[", id, " ", name, "]"]) + end + end + + def file(base) do + to_charlist(Nola.data_path() <> "/account_#{base}.dets") + end + + defp from_struct(%__MODULE__{id: id, name: name, token: token}) do + {id, name, token} + end + + defp from_tuple({id, name, token}) do + %__MODULE__{id: id, name: name, token: token} + end + + def start_link() do + GenServer.start_link(__MODULE__, [], [name: __MODULE__]) + end + + def init(_) do + {:ok, accounts} = :dets.open_file(file("db"), []) + {:ok, meta} = :dets.open_file(file("meta"), []) + {:ok, predicates} = :dets.open_file(file("predicates"), [{:type, :set}]) + {:ok, %{accounts: accounts, meta: meta, predicates: predicates}} + end + + def get(id) do + case :dets.lookup(file("db"), id) do + [account] -> from_tuple(account) + _ -> nil + end + end + + def get_by_name(name) do + spec = [{{:_, :"$1", :_}, [{:==, :"$1", {:const, name}}], [:"$_"]}] + case :dets.select(file("db"), spec) do + [account] -> from_tuple(account) + _ -> nil + end + end + + def get_meta(%__MODULE__{id: id}, key, default \\ nil) do + case :dets.lookup(file("meta"), {id, key}) do + [{_, value}] -> (value || default) + _ -> default + end + end + + @spec find_meta_accounts(String.t()) :: [{account :: t(), value :: String.t()}, ...] + @doc "Find all accounts that have a meta of `key`." + def find_meta_accounts(key) do + spec = [{{{:"$1", :"$2"}, :"$3"}, [{:==, :"$2", {:const, key}}], [{{:"$1", :"$3"}}]}] + for {id, val} <- :dets.select(file("meta"), spec), do: {get(id), val} + end + + @doc "Find an account given a specific meta `key` and `value`." + @spec find_meta_account(String.t(), String.t()) :: t() | nil + def find_meta_account(key, value) do + #spec = [{{{:"$1", :"$2"}, :"$3"}, [:andalso, {:==, :"$2", {:const, key}}, {:==, :"$3", {:const, value}}], [:"$1"]}] + spec = [{{{:"$1", :"$2"}, :"$3"}, [{:andalso, {:==, :"$2", {:const, key}}, {:==, {:const, value}, :"$3"}}], [:"$1"]}] + case :dets.select(file("meta"), spec) do + [id] -> get(id) + _ -> nil + end + end + + def get_all_meta(%__MODULE__{id: id}) do + spec = [{{{:"$1", :"$2"}, :"$3"}, [{:==, :"$1", {:const, id}}], [{{:"$2", :"$3"}}]}] + :dets.select(file("meta"), spec) + end + + def put_user_meta(account = %__MODULE__{}, key, value) do + put_meta(account, "u:"<>key, value) + end + + def put_meta(%__MODULE__{id: id}, key, value) do + :dets.insert(file("meta"), {{id, key}, value}) + end + + def delete_meta(%__MODULE__{id: id}, key) do + :dets.delete(file("meta"), {id, key}) + end + + def all_accounts() do + :dets.traverse(file("db"), fn(obj) -> {:continue, from_tuple(obj)} end) + end + + def all_predicates() do + :dets.traverse(file("predicates"), fn(obj) -> {:continue, obj} end) + end + + def all_meta() do + :dets.traverse(file("meta"), fn(obj) -> {:continue, obj} end) + end + + def merge_account(old_id, new_id) do + if old_id != new_id do + spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, old_id}}], [:"$1"]}] + predicates = :dets.select(file("predicates"), spec) + for pred <- predicates, do: :ok = :dets.insert(file("predicates"), {pred, new_id}) + spec = [{{{:"$1", :"$2"}, :"$3"}, [{:==, :"$1", {:const, old_id}}], [{{:"$2", :"$3"}}]}] + metas = :dets.select(file("meta"), spec) + for {k,v} <- metas do + :dets.delete(file("meta"), {{old_id, k}}) + :ok = :dets.insert(file("meta"), {{new_id, k}, v}) + end + :dets.delete(file("db"), old_id) + IRC.Membership.merge_account(old_id, new_id) + IRC.UserTrack.merge_account(old_id, new_id) + IRC.Connection.dispatch("account", {:account_change, old_id, new_id}) + IRC.Connection.dispatch("conn", {:account_change, old_id, new_id}) + end + :ok + end + + @doc "Find an account by a logged in user" + def find_by_nick(network, nick) do + do_lookup(%ExIRC.SenderInfo{nick: nick, network: network}, false) + end + + @doc "Always find an account by nickname, even if offline. Uses predicates and then account name." + def find_always_by_nick(network, chan, nick) do + with \ + nil <- find_by_nick(network, nick), + nil <- do_lookup(%User{network: network, nick: nick}, false), + nil <- get_by_name(nick) + do + nil + else + %__MODULE__{} = account -> + memberships = IRC.Membership.of_account(account) + if Enum.any?(memberships, fn({net, ch}) -> (net == network) or (chan && chan == ch) end) do + account + else + nil + end + end + end + + def find(something) do + do_lookup(something, false) + end + + def lookup(something, make_default \\ true) do + account = do_lookup(something, make_default) + if account && Map.get(something, :nick) do + IRC.Connection.dispatch("account", {:account_auth, Map.get(something, :nick), account.id}) + end + account + end + + def handle_info(_, state) do + {:noreply, state} + end + + def handle_cast(_, state) do + {:noreply, state} + end + + def handle_call(_, _, state) do + {:noreply, state} + end + + def terminate(_, state) do + for {_, dets} <- state do + :dets.sync(dets) + :dets.close(dets) + end + end + + defp do_lookup(message = %IRC.Message{account: account_id}, make_default) when is_binary(account_id) do + get(account_id) + end + + defp do_lookup(sender = %ExIRC.Who{}, make_default) do + if user = IRC.UserTrack.find_by_nick(sender) do + lookup(user, make_default) + else + #FIXME this will never work with continued lookup by other methods as Who isn't compatible + lookup_by_nick(sender, :dets.lookup(file("predicates"), {sender.network,{:nick, sender.nick}}), make_default) + end + end + + defp do_lookup(sender = %ExIRC.SenderInfo{}, make_default) do + lookup(IRC.UserTrack.find_by_nick(sender), make_default) + end + + defp do_lookup(user = %User{account: id}, make_default) when is_binary(id) do + get(id) + end + + defp do_lookup(user = %User{network: server, nick: nick}, make_default) do + lookup_by_nick(user, :dets.lookup(file("predicates"), {server,{:nick, nick}}), make_default) + end + + defp do_lookup(nil, _) do + nil + end + + defp lookup_by_nick(_, [{_, id}], _make_default) do + get(id) + end + + defp lookup_by_nick(user, _, make_default) do + #authenticate_by_host(user) + if make_default, do: new_account(user), else: nil + end + + def new_account(nick) do + id = EntropyString.large_id() + :dets.insert(file("db"), {id, nick, EntropyString.token()}) + get(id) + end + + def new_account(%{nick: nick, network: server}) do + id = EntropyString.large_id() + :dets.insert(file("db"), {id, nick, EntropyString.token()}) + :dets.insert(file("predicates"), {{server, {:nick, nick}}, id}) + get(id) + end + + def update_account_name(account = %__MODULE__{id: id}, name) do + account = %__MODULE__{account | name: name} + :dets.insert(file("db"), from_struct(account)) + get(id) + end + + def get_predicates(%__MODULE__{} = account) do + spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, account.id}}], [:"$1"]}] + :dets.select(file("predicates"), spec) + end + +end diff --git a/lib/irc/admin_handler.ex b/lib/irc/admin_handler.ex new file mode 100644 index 0000000..282f3c2 --- /dev/null +++ b/lib/irc/admin_handler.ex @@ -0,0 +1,41 @@ +defmodule Nola.Irc.AdminHandler do + @moduledoc """ + # admin + + !op + op; requiert admin + """ + + def irc_doc, do: nil + + def start_link(client) do + GenServer.start_link(__MODULE__, [client]) + end + + def init([client]) do + ExIRC.Client.add_handler client, self + :ok = IRC.register("op") + {:ok, client} + end + + def handle_info({:irc, :trigger, "op", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang}, sender: sender}}, client) do + if IRC.admin?(sender) do + m.replyfun.({:mode, "+o"}) + else + m.replyfun.({:kick, "non"}) + end + {:noreply, client} + end + + def handle_info({:joined, chan, sender}, client) do + if IRC.admin?(sender) do + ExIRC.Client.mode(client, chan, "+o", sender.nick) + end + {:noreply, client} + end + + def handle_info(msg, client) do + {:noreply, client} + end + +end diff --git a/lib/irc/connection.ex b/lib/irc/connection.ex new file mode 100644 index 0000000..86d8279 --- /dev/null +++ b/lib/irc/connection.ex @@ -0,0 +1,521 @@ +defmodule IRC.Connection do + require Logger + use Ecto.Schema + + @moduledoc """ + # IRC Connection + + Provides a nicer abstraction over ExIRC's handlers. + + ## Start connections + + ``` + IRC.Connection.start_link(host: "irc.random.sh", port: 6697, nick: "pouetbot", channels: ["#dev"]) + + ## PubSub topics + + * `account` -- accounts change + * {:account_change, old_account_id, new_account_id} # Sent when account merged + * {:accounts, [{:account, network, channel, nick, account_id}] # Sent on bot join + * {:account, network, nick, account_id} # Sent on user join + * `message` -- aill messages (without triggers) + * `message:private` -- all messages without a channel + * `message:#CHANNEL` -- all messages within `#CHANNEL` + * `triggers` -- all triggers + * `trigger:TRIGGER` -- any message with a trigger `TRIGGER` + + ## Replying to %IRC.Message{} + + Each `IRC.Message` comes with a dedicated `replyfun`, to which you only have to pass either: + + """ + def irc_doc, do: nil + + @min_backoff :timer.seconds(5) + @max_backoff :timer.seconds(2*60) + + embedded_schema do + field :network, :string + field :host, :string + field :port, :integer + field :nick, :string + field :user, :string + field :name, :string + field :pass, :string + field :tls, :boolean, default: false + field :channels, {:array, :string}, default: [] + end + + defmodule Supervisor do + use DynamicSupervisor + + def start_link() do + DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__) + end + + def start_child(%IRC.Connection{} = conn) do + spec = %{id: conn.id, start: {IRC.Connection, :start_link, [conn]}, restart: :transient} + DynamicSupervisor.start_child(__MODULE__, spec) + end + + @impl true + def init(_init_arg) do + DynamicSupervisor.init( + strategy: :one_for_one, + max_restarts: 10, + max_seconds: 1 + ) + end + end + + + def changeset(params) do + import Ecto.Changeset + + %__MODULE__{id: EntropyString.large_id()} + |> cast(params, [:network, :host, :port, :nick, :user, :name, :pass, :channels, :tls]) + |> validate_required([:host, :port, :nick, :user, :name]) + |> apply_action(:insert) + end + + def to_tuple(%__MODULE__{} = conn) do + {conn.id, conn.network, conn.host, conn.port, conn.nick, conn.user, conn.name, conn.pass, conn.tls, conn.channels, nil} + end + + def from_tuple({id, network, host, port, nick, user, name, pass, tls, channels, _}) do + %__MODULE__{id: id, network: network, host: host, port: port, nick: nick, user: user, name: name, pass: pass, tls: tls, channels: channels} + end + + ## -- MANAGER API + + def setup() do + :dets.open_file(dets(), []) + end + + def dets(), do: to_charlist(Nola.data_path("/connections.dets")) + + def lookup(id) do + case :dets.lookup(dets(), id) do + [object | _] -> from_tuple(object) + _ -> nil + end + end + + def connections() do + :dets.foldl(fn(object, acc) -> [from_tuple(object) | acc] end, [], dets()) + end + + def start_all() do + for conn <- connections(), do: {conn, IRC.Connection.Supervisor.start_child(conn)} + end + + def get_network(network, channel \\ nil) do + spec = [{{:_, :"$1", :_, :_, :_, :_, :_, :_, :_, :_, :_}, + [{:==, :"$1", {:const, network}}], [:"$_"]}] + results = Enum.map(:dets.select(dets(), spec), fn(object) -> from_tuple(object) end) + if channel do + Enum.find(results, fn(conn) -> Enum.member?(conn.channels, channel) end) + else + List.first(results) + end + end + + def get_host_nick(host, port, nick) do + spec = [{{:_, :_, :"$1", :"$2", :"$3", :_, :_, :_, :_, :_, :_}, + [{:andalso, + {:andalso, {:==, :"$1", {:const, host}}, {:==, :"$2", {:const, port}}}, + {:==, :"$3", {:const, nick}}}], + [:"$_"]} + ] + case :dets.select(dets(), spec) do + [object] -> from_tuple(object) + [] -> nil + end + end + + def delete_connection(%__MODULE__{id: id} = conn) do + :dets.delete(dets(), id) + stop_connection(conn) + :ok + end + + def start_connection(%__MODULE__{} = conn) do + IRC.Connection.Supervisor.start_child(conn) + end + + def stop_connection(%__MODULE__{id: id}) do + case :global.whereis_name(id) do + pid when is_pid(pid) -> + GenServer.stop(pid, :normal) + _ -> :error + end + end + + def add_connection(opts) do + case changeset(opts) do + {:ok, conn} -> + if existing = get_host_nick(conn.host, conn.port, conn.nick) do + {:error, {:existing, conn}} + else + :dets.insert(dets(), to_tuple(conn)) + IRC.Connection.Supervisor.start_child(conn) + end + error -> error + end + end + + def update_connection(connection) do + :dets.insert(dets(), to_tuple(connection)) + end + + def start_link(conn) do + GenServer.start_link(__MODULE__, [conn], name: {:global, conn.id}) + end + + def broadcast_message(net, chan, message) do + dispatch("conn", {:broadcast, net, chan, message}, IRC.ConnectionPubSub) + end + def broadcast_message(list, message) when is_list(list) do + for {net, chan} <- list do + broadcast_message(net, chan, message) + end + end + + def privmsg(channel, line) do + GenServer.cast(__MODULE__, {:privmsg, channel, line}) + end + + def init([conn]) do + Logger.metadata(conn: conn.id) + backoff = :backoff.init(@min_backoff, @max_backoff) + |> :backoff.type(:jitter) + {:ok, %{client: nil, backoff: backoff, conn: conn, connected_server: nil, connected_port: nil, network: conn.network}, {:continue, :connect}} + end + + @triggers %{ + "!" => :bang, + "+" => :plus, + "-" => :minus, + "?" => :query, + "." => :dot, + "~" => :tilde, + "@" => :at, + "++" => :plus_plus, + "--" => :minus_minus, + "!!" => :bang_bang, + "??" => :query_query, + ".." => :dot_dot, + "~~" => :tilde_tilde, + "@@" => :at_at + } + + def handle_continue(:connect, state) do + client_opts = [] + |> Keyword.put(:network, state.conn.network) + {:ok, _} = Registry.register(IRC.ConnectionPubSub, "conn", []) + client = if state.client && Process.alive?(state.client) do + Logger.info("Reconnecting client") + state.client + else + Logger.info("Connecting") + {:ok, client} = ExIRC.Client.start_link(debug: false) + ExIRC.Client.add_handler(client, self()) + client + end + + opts = [{:nodelay, true}] + conn_fun = if state.conn.tls, do: :connect_ssl!, else: :connect! + apply(ExIRC.Client, conn_fun, [client, to_charlist(state.conn.host), state.conn.port, opts]) + + {:noreply, %{state | client: client}} + end + + def handle_info(:disconnected, state) do + {delay, backoff} = :backoff.fail(state.backoff) + Logger.info("#{inspect(self())} Disconnected -- reconnecting in #{inspect delay}ms") + Process.send_after(self(), :connect, delay) + {:noreply, %{state | backoff: backoff}} + end + + def handle_info(:connect, state) do + {:noreply, state, {:continue, :connect}} + end + + def handle_cast({:privmsg, channel, line}, state) do + irc_reply(state, {channel, nil}, line) + {:noreply, state} + end + + # Connection successful + def handle_info({:connected, server, port}, state) do + Logger.info("#{inspect(self())} Connected to #{inspect(server)}:#{port} #{inspect state}") + {_, backoff} = :backoff.succeed(state.backoff) + ExIRC.Client.logon(state.client, state.conn.pass || "", state.conn.nick, state.conn.user, state.conn.name) + {:noreply, %{state | backoff: backoff, connected_server: server, connected_port: port}} + end + + # Logon successful + def handle_info(:logged_in, state) do + Logger.info("#{inspect(self())} Logged in") + {_, backoff} = :backoff.succeed(state.backoff) + Enum.map(state.conn.channels, &ExIRC.Client.join(state.client, &1)) + {:noreply, %{state | backoff: backoff}} + end + + # ISUP + def handle_info({:isup, network}, state) when is_binary(network) do + IRC.UserTrack.clear_network(state.network) + if network != state.network do + Logger.warn("Possibly misconfigured network: #{network} != #{state.network}") + end + {:noreply, state} + end + + # Been kicked + def handle_info({:kicked, _sender, chan, _reason}, state) do + ExIRC.Client.join(state.client, chan) + {:noreply, state} + end + + # Received something in a channel + def handle_info({:received, text, sender, chan}, state) do + user = if user = IRC.UserTrack.find_by_nick(state.network, sender.nick) do + user + else + Logger.error("Could not lookup user for message: #{inspect {state.network, chan, sender.nick}}") + user = IRC.UserTrack.joined(chan, sender, []) + ExIRC.Client.who(state.client, chan) # Rewho everything in case of need ? We shouldn't not know that user.. + user + end + if !user do + ExIRC.Client.who(state.client, chan) # Rewho everything in case of need ? We shouldn't not know that user.. + Logger.error("Could not lookup user nor create it for message: #{inspect {state.network, chan, sender.nick}}") + else + if !Map.get(user.options, :puppet) do + reply_fun = fn(text) -> irc_reply(state, {chan, sender}, text) end + account = IRC.Account.lookup(sender) + message = %IRC.Message{id: FlakeId.get(), transport: :irc, at: NaiveDateTime.utc_now(), text: text, network: state.network, + account: account, sender: sender, channel: chan, replyfun: reply_fun, + trigger: extract_trigger(text)} + message = case IRC.UserTrack.messaged(message) do + :ok -> message + {:ok, message} -> message + end + publish(message, ["#{message.network}/#{chan}:messages"]) + end + end + {:noreply, state} + end + + # Received a private message + def handle_info({:received, text, sender}, state) do + reply_fun = fn(text) -> irc_reply(state, {sender.nick, sender}, text) end + account = IRC.Account.lookup(sender) + message = %IRC.Message{id: FlakeId.get(), transport: :irc, text: text, network: state.network, at: NaiveDateTime.utc_now(), + account: account, sender: sender, replyfun: reply_fun, trigger: extract_trigger(text)} + message = case IRC.UserTrack.messaged(message) do + :ok -> message + {:ok, message} -> message + end + publish(message, ["messages:private", "#{message.network}/#{account.id}:messages"]) + {:noreply, state} + end + + ## -- Broadcast + def handle_info({:broadcast, net, account = %IRC.Account{}, message}, state) do + if net == state.conn.network do + user = IRC.UserTrack.find_by_account(net, account) + if user do + irc_reply(state, {user.nick, nil}, message) + end + end + {:noreply, state} + end + def handle_info({:broadcast, net, chan, message}, state) do + if net == state.conn.network && Enum.member?(state.conn.channels, chan) do + irc_reply(state, {chan, nil}, message) + end + {:noreply, state} + end + + ## -- UserTrack + + def handle_info({:joined, channel}, state) do + ExIRC.Client.who(state.client, channel) + {:noreply, state} + end + + def handle_info({:who, channel, whos}, state) do + accounts = Enum.map(whos, fn(who = %ExIRC.Who{nick: nick, operator?: operator}) -> + priv = if operator, do: [:operator], else: [] + # Don't touch -- on WHO the bot joined, not the users. + IRC.UserTrack.joined(channel, who, priv, false) + account = IRC.Account.lookup(who) + if account do + {:account, who.network, channel, who.nick, account.id} + end + end) + |> Enum.filter(fn(x) -> x end) + dispatch("account", {:accounts, accounts}) + {:noreply, state} + end + + def handle_info({:quit, reason, sender}, state) do + IRC.UserTrack.quitted(sender, reason) + {:noreply, state} + end + + def handle_info({:joined, channel, sender}, state) do + IRC.UserTrack.joined(channel, sender, []) + account = IRC.Account.lookup(sender) + if account do + dispatch("account", {:account, sender.network, channel, sender.nick, account.id}) + end + {:noreply, state} + end + + def handle_info({:kicked, nick, _by, channel, _reason}, state) do + IRC.UserTrack.parted(state.network, channel, nick) + {:noreply, state} + end + + def handle_info({:parted, channel, %ExIRC.SenderInfo{nick: nick}}, state) do + IRC.UserTrack.parted(state.network, channel, nick) + {:noreply, state} + end + + def handle_info({:mode, [channel, mode, nick]}, state) do + track_mode(state.network, channel, nick, mode) + {:noreply, state} + end + + def handle_info({:nick_changed, old_nick, new_nick}, state) do + IRC.UserTrack.renamed(state.network, old_nick, new_nick) + {:noreply, state} + end + + def handle_info(unhandled, client) do + Logger.debug("unhandled: #{inspect unhandled}") + {:noreply, client} + end + + def publish(pub), do: publish(pub, []) + + def publish(m = %IRC.Message{trigger: nil}, keys) do + dispatch(["messages"] ++ keys, {:irc, :text, m}) + end + + def publish(m = %IRC.Message{trigger: t = %IRC.Trigger{trigger: trigger}}, keys) do + dispatch(["triggers", "#{m.network}/#{m.channel}:triggers", "trigger:"<>trigger], {:irc, :trigger, trigger, m}) + end + + def publish_event(net, event = %{type: _}) when is_binary(net) do + event = event + |> Map.put(:at, NaiveDateTime.utc_now()) + |> Map.put(:network, net) + dispatch("#{net}:events", {:irc, :event, event}) + end + def publish_event({net, chan}, event = %{type: type}) do + event = event + |> Map.put(:at, NaiveDateTime.utc_now()) + |> Map.put(:network, net) + |> Map.put(:channel, chan) + dispatch("#{net}/#{chan}:events", {:irc, :event, event}) + end + + def dispatch(keys, content, sub \\ IRC.PubSub) + + def dispatch(key, content, sub) when is_binary(key), do: dispatch([key], content, sub) + def dispatch(keys, content, sub) when is_list(keys) do + Logger.debug("dispatch #{inspect keys} = #{inspect content}") + for key <- keys do + spawn(fn() -> Registry.dispatch(sub, key, fn h -> + for {pid, _} <- h, do: send(pid, content) + end) end) + end + end + + # + # Triggers + # + + def triggers, do: @triggers + + for {trigger, name} <- @triggers do + def extract_trigger(unquote(trigger)<>text) do + text = String.strip(text) + [trigger | args] = String.split(text, " ") + %IRC.Trigger{type: unquote(name), trigger: String.downcase(trigger), args: args} + end + end + + def extract_trigger(_), do: nil + + # + # IRC Replies + # + + # irc_reply(ExIRC.Client pid, {channel or nick, ExIRC.Sender}, binary | replies + # replies :: {:kick, reason} | {:kick, nick, reason} | {:mode, mode, nick} + defp irc_reply(state = %{client: client, network: network}, {target, _}, text) when is_binary(text) or is_list(text) do + lines = IRC.splitlong(text) + |> Enum.map(fn(x) -> if(is_list(x), do: x, else: String.split(x, "\n")) end) + |> List.flatten() + outputs = for line <- lines do + ExIRC.Client.msg(client, :privmsg, target, line) + {:irc, :out, %IRC.Message{id: FlakeId.get(), transport: :irc, network: network, + channel: target, text: line, sender: %ExIRC.SenderInfo{nick: state.conn.nick}, at: NaiveDateTime.utc_now(), meta: %{self: true}}} + end + for f <- outputs, do: dispatch(["irc:outputs", "#{network}/#{target}:outputs"], f) + end + + defp irc_reply(%{client: client}, {target, %{nick: nick}}, {:kick, reason}) do + ExIRC.Client.kick(client, target, nick, reason) + end + + defp irc_reply(%{client: client}, {target, _}, {:kick, nick, reason}) do + ExIRC.Client.kick(client, target, nick, reason) + end + + defp irc_reply(%{client: client}, {target, %{nick: nick}}, {:mode, mode}) do + ExIRC.Client.mode(%{client: client}, target, mode, nick) + end + + defp irc_reply(%{client: client}, target, {:mode, mode, nick}) do + ExIRC.Client.mode(client, target, mode, nick) + end + + defp irc_reply(%{client: client}, target, {:channel_mode, mode}) do + ExIRC.Client.mode(client, target, mode) + end + + defp track_mode(network, channel, nick, "+o") do + IRC.UserTrack.change_privileges(network, channel, nick, {[:operator], []}) + :ok + end + + defp track_mode(network, channel, nick, "-o") do + IRC.UserTrack.change_privileges(network, channel, nick, {[], [:operator]}) + :ok + end + + defp track_mode(network, channel, nick, "+v") do + IRC.UserTrack.change_privileges(network, channel, nick, {[:voice], []}) + :ok + end + + defp track_mode(network, channel, nick, "-v") do + IRC.UserTrack.change_privileges(network, channel, nick, {[], [:voice]}) + :ok + end + + defp track_mode(network, channel, nick, mode) do + Logger.warn("Unhandled track_mode: #{inspect {nick, mode}}") + :ok + end + + defp server(%{conn: %{host: host, port: port}}) do + host <> ":" <> to_string(port) + end + +end diff --git a/lib/irc/conns.ex b/lib/irc/conns.ex new file mode 100644 index 0000000..b0e5d3c --- /dev/null +++ b/lib/irc/conns.ex @@ -0,0 +1,3 @@ +defmodule IRC.Conns do + +end diff --git a/lib/irc/irc.ex b/lib/irc/irc.ex new file mode 100644 index 0000000..71d6d93 --- /dev/null +++ b/lib/irc/irc.ex @@ -0,0 +1,79 @@ +defmodule IRC do + + defmodule Message do + @derive {Poison.Encoder, except: [:replyfun]} + defstruct [:id, + :text, + {:transport, :irc}, + :network, + :account, + :sender, + :channel, + :trigger, + :replyfun, + :at, + {:meta, %{}} + ] + end + defmodule Trigger do + @derive Poison.Encoder + defstruct [:type, :trigger, :args] + end + + def send_message_as(account, network, channel, text, force_puppet \\ false) do + connection = IRC.Connection.get_network(network) + if connection && (force_puppet || IRC.PuppetConnection.whereis(account, connection)) do + IRC.PuppetConnection.start_and_send_message(account, connection, channel, text) + else + user = IRC.UserTrack.find_by_account(network, account) + nick = if(user, do: user.nick, else: account.name) + IRC.Connection.broadcast_message(network, channel, "<#{nick}> #{text}") + end + end + + def register(key) do + case Registry.register(IRC.PubSub, key, []) do + {:ok, _} -> :ok + error -> error + end + end + + def admin?(%Message{sender: sender}), do: admin?(sender) + + def admin?(%{nick: nick, user: user, host: host}) do + for {n, u, h} <- Nola.IRC.env(:admins, []) do + admin_part_match?(n, nick) && admin_part_match?(u, user) && admin_part_match?(h, host) + end + |> Enum.any? + end + + defp admin_part_match?(:_, _), do: true + defp admin_part_match?(a, a), do: true + defp admin_part_match?(_, _), do: false + + @max_chars 440 + + def splitlong(string, max_chars \\ 440) + + def splitlong(string, max_chars) when is_list(string) do + Enum.map(string, fn(s) -> splitlong(s, max_chars) end) + |> List.flatten() + end + + def splitlong(string, max_chars) do + string + |> String.codepoints + |> Enum.chunk_every(max_chars) + |> Enum.map(&Enum.join/1) + end + + def splitlong_with_prefix(string, prefix, max_chars \\ 440) do + prefix = "#{prefix} " + max_chars = max_chars - (length(String.codepoints(prefix))) + string + |> String.codepoints + |> Enum.chunk_every(max_chars) + |> Enum.map(fn(line) -> prefix <> Enum.join(line) end) + end + +end diff --git a/lib/irc/membership.ex b/lib/irc/membership.ex new file mode 100644 index 0000000..b727dfd --- /dev/null +++ b/lib/irc/membership.ex @@ -0,0 +1,129 @@ +defmodule IRC.Membership do + @moduledoc """ + Memberships (users in channels) + """ + + # Key: {account, net, channel} + # Format: {key, last_seen} + + defp dets() do + to_charlist(Nola.data_path <> "/memberships.dets") + end + + def start_link() do + GenServer.start_link(__MODULE__, [], [name: __MODULE__]) + end + + def init(_) do + dets = :dets.open_file(dets(), []) + {:ok, dets} + end + + def of_account(%IRC.Account{id: id}) do + spec = [{{{:"$1", :"$2", :"$3"}, :_}, [{:==, :"$1", {:const, id}}], [{{:"$2", :"$3"}}]}] + :dets.select(dets(), spec) + end + + def merge_account(old_id, new_id) do + #iex(37)> :ets.fun2ms(fn({{old_id, _, _}, _}=obj) when old_id == "42" -> obj end) + spec = [{{{:"$1", :_, :_}, :_}, [{:==, :"$1", {:const, old_id}}], [:"$_"]}] + Util.ets_mutate_select_each(:dets, dets(), spec, fn(table, obj = {{_old, net, chan}, ts}) -> + :dets.delete_object(table, obj) + :dets.insert(table, {{new_id, net, chan}, ts}) + end) + end + + def touch(%IRC.Account{id: id}, network, channel) do + :dets.insert(dets(), {{id, network, channel}, NaiveDateTime.utc_now()}) + end + def touch(account_id, network, channel) do + if account = IRC.Account.get(account_id) do + touch(account, network, channel) + end + end + + def notify_channels(account, minutes \\ 30, last_active \\ true) do + not_before = NaiveDateTime.add(NaiveDateTime.utc_now(), (minutes*-60), :second) + spec = [{{{:"$1", :_, :_}, :_}, [{:==, :"$1", {:const, account.id}}], [:"$_"]}] + memberships = :dets.select(dets(), spec) + |> Enum.sort_by(fn({_, ts}) -> ts end, {:desc, NaiveDateTime}) + active_memberships = Enum.filter(memberships, fn({_, ts}) -> NaiveDateTime.compare(ts, not_before) == :gt end) + cond do + active_memberships == [] && last_active -> + case memberships do + [{{_, net, chan}, _}|_] -> [{net, chan}] + _ -> [] + end + active_memberships == [] -> + [] + true -> + Enum.map(active_memberships, fn({{_, net, chan}, _}) -> {net,chan} end) + end + end + + def members_or_friends(account, _network, nil) do + friends(account) + end + + def members_or_friends(_, network, channel) do + members(network, channel) + end + + def expanded_members_or_friends(account, network, channel) do + expand(network, members_or_friends(account, network, channel)) + end + + def expanded_members(network, channel) do + expand(network, members(network, channel)) + end + + def members(network, channel) do + #iex(19)> :ets.fun2ms(fn({{id, net, chan}, ts}) when net == network and chan == channel and ts > min_seen -> id end) + limit = 0 # NaiveDateTime.add(NaiveDateTime.utc_now, 30*((24*-60)*60), :second) + spec = [ + {{{:"$1", :"$2", :"$3"}, :"$4"}, + [ + {:andalso, + {:andalso, {:==, :"$2", {:const, network}}, {:==, :"$3", {:const, channel}}}, + {:>, :"$4", {:const, limit}}} + ], [:"$1"]} + ] + :dets.select(dets(), spec) + end + + def friends(account = %IRC.Account{id: id}) do + for({net, chan} <- of_account(account), do: members(net, chan)) + |> List.flatten() + |> Enum.uniq() + end + + def handle_info(_, dets) do + {:noreply, dets} + end + + def handle_cast(_, dets) do + {:noreply, dets} + end + + def handle_call(_, _, dets) do + {:noreply, dets} + end + + def terminate(_, dets) do + :dets.sync(dets) + :dets.close(dets) + end + + defp expand(network, list) do + for id <- list do + if account = IRC.Account.get(id) do + user = IRC.UserTrack.find_by_account(network, account) + nick = if(user, do: user.nick, else: account.name) + {account, user, nick} + end + end + |> Enum.filter(fn(x) -> x end) + end + + +end diff --git a/lib/irc/nola_irc.ex b/lib/irc/nola_irc.ex new file mode 100644 index 0000000..870b99d --- /dev/null +++ b/lib/irc/nola_irc.ex @@ -0,0 +1,34 @@ +defmodule Nola.IRC do + require Logger + + def env(), do: Nola.env(:irc) + def env(key, default \\ nil), do: Keyword.get(env(), key, default) + + def application_childs do + import Supervisor.Spec + + IRC.Connection.setup() + Nola.Plugins.setup() + + [ + worker(Registry, [[keys: :duplicate, name: IRC.ConnectionPubSub]], id: :registr_irc_conn), + worker(Registry, [[keys: :duplicate, name: IRC.PubSub]], id: :registry_irc), + worker(IRC.Membership, []), + worker(IRC.Account, []), + worker(IRC.UserTrack.Storage, []), + worker(Nola.Plugins.Account, []), + supervisor(Nola.Plugins.Supervisor, [], [name: Nola.Plugins.Supervisor]), + supervisor(IRC.Connection.Supervisor, [], [name: IRC.Connection.Supervisor]), + supervisor(IRC.PuppetConnection.Supervisor, [], [name: IRC.PuppetConnection.Supervisor]), + ] + end + + # Start plugins first to let them get on connection events. + def after_start() do + Logger.info("Starting plugins") + Nola.Plugins.start_all() + Logger.info("Starting connections") + IRC.Connection.start_all() + end + +end diff --git a/lib/irc/puppet_connection.ex b/lib/irc/puppet_connection.ex new file mode 100644 index 0000000..91a26b3 --- /dev/null +++ b/lib/irc/puppet_connection.ex @@ -0,0 +1,238 @@ +defmodule IRC.PuppetConnection do + require Logger + @min_backoff :timer.seconds(5) + @max_backoff :timer.seconds(2*60) + @max_idle :timer.hours(12) + @env Mix.env + + defmodule Supervisor do + use DynamicSupervisor + + def start_link() do + DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__) + end + + def start_child(%IRC.Account{id: account_id}, %IRC.Connection{id: connection_id}) do + spec = %{id: {account_id, connection_id}, start: {IRC.PuppetConnection, :start_link, [account_id, connection_id]}, restart: :transient} + DynamicSupervisor.start_child(__MODULE__, spec) + end + + @impl true + def init(_init_arg) do + DynamicSupervisor.init( + strategy: :one_for_one, + max_restarts: 10, + max_seconds: 1 + ) + end + end + + def whereis(account = %IRC.Account{id: account_id}, connection = %IRC.Connection{id: connection_id}) do + {:global, name} = name(account_id, connection_id) + case :global.whereis_name(name) do + :undefined -> nil + pid -> pid + end + end + + def send_message(account = %IRC.Account{id: account_id}, connection = %IRC.Connection{id: connection_id}, channel, text) do + GenServer.cast(name(account_id, connection_id), {:send_message, self(), channel, text}) + end + + def start_and_send_message(account = %IRC.Account{id: account_id}, connection = %IRC.Connection{id: connection_id}, channel, text) do + {:global, name} = name(account_id, connection_id) + pid = whereis(account, connection) + pid = if !pid do + case IRC.PuppetConnection.Supervisor.start_child(account, connection) do + {:ok, pid} -> pid + {:error, {:already_started, pid}} -> pid + end + else + pid + end + GenServer.cast(pid, {:send_message, self(), channel, text}) + end + + def start(account = %IRC.Account{}, connection = %IRC.Connection{}) do + IRC.PuppetConnection.Supervisor.start_child(account, connection) + end + + def start_link(account_id, connection_id) do + GenServer.start_link(__MODULE__, [account_id, connection_id], name: name(account_id, connection_id)) + end + + def name(account_id, connection_id) do + {:global, {PuppetConnection, account_id, connection_id}} + end + + def init([account_id, connection_id]) do + account = %IRC.Account{} = IRC.Account.get(account_id) + connection = %IRC.Connection{} = IRC.Connection.lookup(connection_id) + Logger.metadata(puppet_conn: account.id <> "@" <> connection.id) + backoff = :backoff.init(@min_backoff, @max_backoff) + |> :backoff.type(:jitter) + idle = :erlang.send_after(@max_idle, self, :idle) + {:ok, %{client: nil, backoff: backoff, idle: idle, connected: false, buffer: [], channels: [], connection_id: connection_id, account_id: account_id, connected_server: nil, connected_port: nil, network: connection.network}, {:continue, :connect}} + end + + def handle_continue(:connect, state) do + #ipv6 = if @env == :prod do + # subnet = Nola.Subnet.assign(state.account_id) + # IRC.Account.put_meta(IRC.Account.get(state.account_id), "subnet", subnet) + # ip = Pfx.host(subnet, 1) + # {:ok, ipv6} = :inet_parse.ipv6_address(to_charlist(ip)) + # System.cmd("add-ip6", [ip]) + # ipv6 + #end + + conn = IRC.Connection.lookup(state.connection_id) + client_opts = [] + |> Keyword.put(:network, conn.network) + client = if state.client && Process.alive?(state.client) do + Logger.info("Reconnecting client") + state.client + else + Logger.info("Connecting") + {:ok, client} = ExIRC.Client.start_link(debug: false) + ExIRC.Client.add_handler(client, self()) + client + end + + base_opts = [ + {:nodelay, true} + ] + + #{ip, opts} = case {ipv6, :inet_res.resolve(to_charlist(conn.host), :in, :aaaa)} do + # {ipv6, {:ok, {:dns_rec, _dns_header, _query, rrs = [{:dns_rr, _, _, _, _, _, _, _, _, _} | _], _, _}}} -> + # ip = rrs + # |> Enum.map(fn({:dns_rr, _, :aaaa, :in, _, _, ipv6, _, _, _}) -> ipv6 end) + # |> Enum.shuffle() + # |> List.first() + + # opts = [ + # :inet6, + # {:ifaddr, ipv6} + # ] + # {ip, opts} + # _ -> + {ip, opts} = {to_charlist(conn.host), []} + #end + + conn_fun = if conn.tls, do: :connect_ssl!, else: :connect! + apply(ExIRC.Client, conn_fun, [client, ip, conn.port, base_opts ++ opts]) + + {:noreply, %{state | client: client}} + end + + def handle_continue(:connected, state) do + state = Enum.reduce(Enum.reverse(state.buffer), state, fn(b, state) -> + {:noreply, state} = handle_cast(b, state) + state + end) + {:noreply, %{state | buffer: []}} + end + + def handle_cast(cast = {:send_message, _pid, _channel, _text}, state = %{connected: false, buffer: buffer}) do + {:noreply, %{state | buffer: [cast | buffer]}} + end + + def handle_cast({:send_message, pid, channel, text}, state = %{connected: true}) do + channels = if !Enum.member?(state.channels, channel) do + ExIRC.Client.join(state.client, channel) + [channel | state.channels] + else + state.channels + end + ExIRC.Client.msg(state.client, :privmsg, channel, text) + + meta = %{puppet: true, from: pid} + account = IRC.Account.get(state.account_id) + nick = make_nick(state) + sender = %ExIRC.SenderInfo{network: state.network, nick: suffix_nick(nick), user: nick, host: "puppet."} + reply_fun = fn(text) -> + IRC.Connection.broadcast_message(state.network, channel, text) + end + message = %IRC.Message{id: FlakeId.get(), at: NaiveDateTime.utc_now(), text: text, network: state.network, account: account, sender: sender, channel: channel, replyfun: reply_fun, trigger: IRC.Connection.extract_trigger(text), meta: meta} + message = case IRC.UserTrack.messaged(message) do + :ok -> message + {:ok, message} -> message + end + IRC.Connection.publish(message, ["#{message.network}/#{channel}:messages"]) + + idle = if length(state.buffer) == 0 do + :erlang.cancel_timer(state.idle) + :erlang.send_after(@max_idle, self(), :idle) + else + state.idle + end + + {:noreply, %{state | idle: idle, channels: channels}} + end + + def handle_info(:idle, state) do + ExIRC.Client.quit(state.client, "Puppet was idle for too long") + ExIRC.Client.stop!(state.client) + {:stop, :normal, state} + end + + def handle_info(:disconnected, state) do + {delay, backoff} = :backoff.fail(state.backoff) + Logger.info("#{inspect(self())} Disconnected -- reconnecting in #{inspect delay}ms") + Process.send_after(self(), :connect, delay) + {:noreply, %{state | connected: false, backoff: backoff}} + end + + def handle_info(:connect, state) do + {:noreply, state, {:continue, :connect}} + end + + # Connection successful + def handle_info({:connected, server, port}, state) do + Logger.info("#{inspect(self())} Connected to #{inspect(server)}:#{port} #{inspect state}") + {_, backoff} = :backoff.succeed(state.backoff) + base_nick = make_nick(state) + ExIRC.Client.logon(state.client, "", suffix_nick(base_nick), base_nick, "#{base_nick}'s puppet") + {:noreply, %{state | backoff: backoff, connected_server: server, connected_port: port}} + end + + # Logon successful + def handle_info(:logged_in, state) do + Logger.info("#{inspect(self())} Logged in") + {_, backoff} = :backoff.succeed(state.backoff) + # Create an UserTrack entry for the client so it's authenticated to the right account_id already. + IRC.UserTrack.connected(state.network, suffix_nick(make_nick(state)), make_nick(state), "puppet.", state.account_id, %{puppet: true}) + {:noreply, %{state | backoff: backoff}} + end + + # ISUP + def handle_info({:isup, network}, state) do + {:noreply, %{state | network: network, connected: true}, {:continue, :connected}} + end + + # Been kicked + def handle_info({:kicked, _sender, chan, _reason}, state) do + {:noreply, %{state | channels: state.channels -- [chan]}} + end + + def handle_info(_info, state) do + {:noreply, state} + end + + def make_nick(state) do + account = IRC.Account.get(state.account_id) + user = IRC.UserTrack.find_by_account(state.network, account) + base_nick = if(user, do: user.nick, else: account.name) + clean_nick = case String.split(base_nick, ":", parts: 2) do + ["@"<>nick, _] -> nick + [nick] -> nick + end + clean_nick + end + + if Mix.env == :dev do + def suffix_nick(nick), do: "#{nick}[d]" + else + def suffix_nick(nick), do: "#{nick}[p]" + end + +end diff --git a/lib/irc/user_track.ex b/lib/irc/user_track.ex new file mode 100644 index 0000000..1efa523 --- /dev/null +++ b/lib/irc/user_track.ex @@ -0,0 +1,329 @@ +defmodule IRC.UserTrack do + @moduledoc """ + User Track DB & Utilities + """ + + @ets IRC.UserTrack.Storage + # {uuid, network, nick, nicks, privilege_map} + # Privilege map: + # %{"#channel" => [:operator, :voice] + defmodule Storage do + + def delete(id) do + op(fn(ets) -> :ets.delete(ets, id) end) + end + + def insert(tuple) do + op(fn(ets) -> :ets.insert(ets, tuple) end) + end + + def clear_network(network) do + op(fn(ets) -> + spec = [ + {{:_, :"$1", :_, :_, :_, :_, :_, :_, :_, :_, :_, :_}, + [ + {:==, :"$1", {:const, network}} + ], [:"$_"]} + ] + :ets.match_delete(ets, spec) + end) + end + + def op(fun) do + GenServer.call(__MODULE__, {:op, fun}) + end + + def start_link do + GenServer.start_link(__MODULE__, [], [name: __MODULE__]) + end + + def init([]) do + ets = :ets.new(__MODULE__, [:set, :named_table, :protected, {:read_concurrency, true}]) + {:ok, ets} + end + + def handle_call({:op, fun}, _from, ets) do + returned = try do + {:ok, fun.(ets)} + rescue + rescued -> {:error, rescued} + catch + rescued -> {:error, rescued} + end + {:reply, returned, ets} + end + + def terminate(_reason, ets) do + :ok + end + end + + defmodule Id, do: use EntropyString + + defmodule User do + defstruct [:id, :account, :network, :nick, {:nicks, []}, :username, :host, :realname, {:privileges, %{}}, {:last_active, %{}}, {:options, %{}}] + + def to_tuple(u = %__MODULE__{}) do + {u.id || IRC.UserTrack.Id.large_id, u.network, u.account, String.downcase(u.nick), u.nick, u.nicks || [], u.username, u.host, u.realname, u.privileges, u.last_active, u.options} + end + + #tuple size: 11 + def from_tuple({id, network, account, _downcased_nick, nick, nicks, username, host, realname, privs, last_active, opts}) do + struct = %__MODULE__{id: id, account: account, network: network, nick: nick, nicks: nicks, username: username, host: host, realname: realname, privileges: privs, last_active: last_active, options: opts} + end + end + + def find_by_account(%IRC.Account{id: id}) do + #iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end) + spec = [ + {{:_, :_, :"$2", :_, :_, :_, :_, :_, :_, :_, :_, :_}, + [ + {:==, :"$2", {:const, id}} + ], [:"$_"]} + ] + results = :ets.select(@ets, spec) + |> Enum.filter(& &1) + for obj <- results, do: User.from_tuple(obj) + end + + def find_by_account(network, nil) do + nil + end + + def find_by_account(network, %IRC.Account{id: id}) do + #iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end) + spec = [ + {{:_, :"$1", :"$2", :_, :_, :_, :_, :_, :_, :_, :_, :_}, + [ + {:andalso, {:==, :"$1", {:const, network}}, + {:==, :"$2", {:const, id}}} + ], [:"$_"]} + ] + case :ets.select(@ets, spec) do + results = [_r | _] -> + result = results + |> Enum.reject(fn({_, net, _, _, _, _, _, _, _, _, actives, opts}) -> network != "matrix" && net == "matrix" end) + |> Enum.reject(fn({_, net, _, _, _, _, _, _, _, _, actives, opts}) -> network != "telegram" && net == "telegram" end) + |> Enum.reject(fn({_, _, _, _, _, _, _, _, _, _, actives, opts}) -> network not in ["matrix", "telegram"] && Map.get(opts, :puppet) end) + |> Enum.sort_by(fn({_, _, _, _, _, _, _, _, _, _, actives, _}) -> + Map.get(actives, nil) + end, {:desc, NaiveDateTime}) + |> List.first + + if result, do: User.from_tuple(result) + _ -> nil + end + end + + def clear_network(network) do + Storage.clear_network(network) + end + + + def merge_account(old_id, new_id) do + #iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end) + spec = [ + {{:_, :_, :"$1", :_, :_, :_, :_, :_, :_, :_, :_, :_}, + [ + {:==, :"$1", {:const, old_id}} + ], [:"$_"]} + ] + Enum.each(:ets.select(@ets, spec), fn({id, net, _, downcased_nick, nick, nicks, username, host, realname, privs, active, opts}) -> + Storage.op(fn(ets) -> + :ets.insert(@ets, {id, net, new_id, downcased_nick, nick, nicks, username, host, realname, privs, active, opts}) + end) + end) + end + + def find_by_nick(%ExIRC.Who{network: network, nick: nick}) do + find_by_nick(network, nick) + end + + + def find_by_nick(%ExIRC.SenderInfo{network: network, nick: nick}) do + find_by_nick(network, nick) + end + + def find_by_nick(network, nick) do + case :ets.match(@ets, {:"$1", network, :_, String.downcase(nick), :_, :_, :_, :_, :_, :_, :_, :_}) do + [[id] | _] -> lookup(id) + _ -> + nil + end + end + + def to_list, do: :ets.tab2list(@ets) + + def lookup(id) do + case :ets.lookup(@ets, id) do + [] -> nil + [tuple] -> User.from_tuple(tuple) + end + end + + def operator?(network, channel, nick) do + if user = find_by_nick(network, nick) do + privs = Map.get(user.privileges, channel, []) + Enum.member?(privs, :admin) || Enum.member?(privs, :operator) + else + false + end + end + + def channel(network, channel) do + Enum.filter(to_list(), fn({_, network, _, _, _, _, _, _, _, channels, _, _}) -> + Map.get(channels, channel) + end) + end + + # TODO + def connected(network, nick, user, host, account_id, opts \\ %{}) do + if account = IRC.Account.get(account_id) do + user = if user = find_by_nick(network, nick) do + user + else + user = %User{id: IRC.UserTrack.Id.large_id, account: account_id, network: network, nick: nick, username: user, host: host, privileges: %{}, options: opts} + Storage.op(fn(ets) -> + :ets.insert(ets, User.to_tuple(user)) + end) + user + end + + IRC.Connection.publish_event(network, %{type: :connect, user_id: user.id, account_id: user.account}) + :ok + else + :error + end + end + + def joined(c, s), do: joined(c,s,[]) + + def joined(channel, sender=%{nick: nick, user: uname, host: host}, privileges, touch \\ true) do + privileges = if IRC.admin?(sender) do + privileges ++ [:admin] + else privileges end + user = if user = find_by_nick(sender.network, nick) do + %User{user | username: uname, host: host, privileges: Map.put(user.privileges || %{}, channel, privileges)} + else + user = %User{id: IRC.UserTrack.Id.large_id, network: sender.network, nick: nick, username: uname, host: host, privileges: %{channel => privileges}} + + account = IRC.Account.lookup(user).id + user = %User{user | account: account} + end + user = touch_struct(user, channel) + + if touch && user.account do + IRC.Membership.touch(user.account, sender.network, channel) + end + + Storage.op(fn(ets) -> + :ets.insert(ets, User.to_tuple(user)) + end) + + IRC.Connection.publish_event({sender.network, channel}, %{type: :join, user_id: user.id, account_id: user.account}) + + user + end + + #def joined(network, channel, nick, privileges) do + # user = if user = find_by_nick(network, nick) do + # %User{user | privileges: Map.put(user.privileges, channel, privileges)} + # else + # %User{nick: nick, privileges: %{channel => privileges}} + # end + # + # Storage.op(fn(ets) -> + # :ets.insert(ets, User.to_tuple(user)) + # end) + #end + + def messaged(%IRC.Message{network: network, account: account, channel: chan, sender: %{nick: nick}} = m) do + {user, account} = if user = find_by_nick(network, nick) do + {touch_struct(user, chan), account || IRC.Account.lookup(user)} + else + user = %User{network: network, nick: nick, privileges: %{}} + account = IRC.Account.lookup(user) + {%User{user | account: account.id}, account} + end + Storage.insert(User.to_tuple(user)) + if chan, do: IRC.Membership.touch(account, network, chan) + if !m.account do + {:ok, %IRC.Message{m | account: account}} + else + :ok + end + end + + def renamed(network, old_nick, new_nick) do + if user = find_by_nick(network, old_nick) do + old_account = IRC.Account.lookup(user) + user = %User{user | nick: new_nick, nicks: [old_nick|user.nicks]} + account = IRC.Account.lookup(user, false) || old_account + user = %User{user | nick: new_nick, account: account.id, nicks: [old_nick|user.nicks]} + Storage.insert(User.to_tuple(user)) + channels = for {channel, _} <- user.privileges, do: channel + IRC.Connection.publish_event(network, %{type: :nick, user_id: user.id, account_id: account.id, nick: new_nick, old_nick: old_nick}) + end + end + + def change_privileges(network, channel, nick, {add, remove}) do + if user = find_by_nick(network, nick) do + privs = Map.get(user.privileges, channel) + + privs = Enum.reduce(add, privs, fn(priv, acc) -> [priv|acc] end) + privs = Enum.reduce(remove, privs, fn(priv, acc) -> List.delete(acc, priv) end) + + user = %User{user | privileges: Map.put(user.privileges, channel, privs)} + Storage.insert(User.to_tuple(user)) + IRC.Connection.publish_event({network, channel}, %{type: :privileges, user_id: user.id, account_id: user.account, added: add, removed: remove}) + end + end + + # XXX: Reason + def parted(channel, %{network: network, nick: nick}) do + parted(network, channel, nick) + end + + def parted(network, channel, nick) do + if user = find_by_nick(network, nick) do + if user.account do + IRC.Membership.touch(user.account, network, channel) + end + + privs = Map.delete(user.privileges, channel) + lasts = Map.delete(user.last_active, channel) + if Enum.count(privs) > 0 do + user = %User{user | privileges: privs} + Storage.insert(User.to_tuple(user)) + IRC.Connection.publish_event({network, channel}, %{type: :part, user_id: user.id, account_id: user.account, reason: nil}) + else + IRC.Connection.publish_event(network, %{type: :quit, user_id: user.id, account_id: user.account, reason: "Left all known channels"}) + Storage.delete(user.id) + end + end + end + + def quitted(sender, reason) do + if user = find_by_nick(sender.network, sender.nick) do + if user.account do + for {channel, _} <- user.privileges do + IRC.Membership.touch(user.account, sender.network, channel) + end + IRC.Connection.publish_event(sender.network, %{type: :quit, user_id: user.id, account_id: user.account, reason: reason}) + end + Storage.delete(user.id) + end + end + + defp touch_struct(user = %User{last_active: last_active}, channel) do + now = NaiveDateTime.utc_now() + last_active = last_active + |> Map.put(channel, now) + |> Map.put(nil, now) + %User{user | last_active: last_active} + end + + defp userchans(%{privileges: privileges}) do + for({chan, _} <- privileges, do: chan) + end +end |