summaryrefslogtreecommitdiff
path: root/lib/irc
diff options
context:
space:
mode:
authorhref <href@random.sh>2020-07-07 21:39:10 +0200
committerhref <href@random.sh>2020-07-07 21:39:51 +0200
commitd6ee134a5957e299c3ad59011df320b3c41e6e61 (patch)
tree29567e6635466f8a3415a935b3cc8a777019f5bc /lib/irc
parentbleh (diff)
pouet
Diffstat (limited to 'lib/irc')
-rw-r--r--lib/irc/account.ex439
-rw-r--r--lib/irc/connection.ex462
-rw-r--r--lib/irc/connection_handler.ex36
-rw-r--r--lib/irc/conns.ex3
-rw-r--r--lib/irc/login_handler.ex27
-rw-r--r--lib/irc/membership.ex129
-rw-r--r--lib/irc/plugin_supervisor.ex91
-rw-r--r--lib/irc/pubsub_handler.ex135
-rw-r--r--lib/irc/user_track.ex177
-rw-r--r--lib/irc/user_track_handler.ex93
10 files changed, 1270 insertions, 322 deletions
diff --git a/lib/irc/account.ex b/lib/irc/account.ex
new file mode 100644
index 0000000..6f9eb05
--- /dev/null
+++ b/lib/irc/account.ex
@@ -0,0 +1,439 @@
+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?
+
+ defstruct [:id, :name, :token]
+ @type t :: %__MODULE__{id: String.t(), name: 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(LSG.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
+
+ defp 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
+
+ defmodule AccountPlugin do
+ @moduledoc """
+ # Account
+
+ * **account** Get current account id and token
+ * **auth `<account-id>` `<token>`** Authenticate and link the current nickname to an account
+ * **auth** list authentications methods
+ * **whoami** list currently authenticated users
+ * **enable-sms** Link a SMS number
+ * **enable-telegram** Link a Telegram account
+ """
+
+ def irc_doc, do: @moduledoc
+ def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__)
+ def init(_) do
+ {:ok, _} = Registry.register(IRC.PubSub, "message:private", [])
+ {:ok, nil}
+ end
+
+ def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "help"}}, state) do
+ text = [
+ "account: show current account and auth token",
+ "auth: show authentications methods",
+ "whoami: list authenticated users",
+ "set-name <name>: set account name",
+ "web: login to web",
+ "enable-sms | disable-sms: enable/change or disable sms",
+ "enable-telegram: link/change telegram",
+ "enable-untappd: link untappd account",
+ "getmeta: show meta datas",
+ "setusermeta: set user meta",
+ ]
+ m.replyfun.(text)
+ {:noreply, state}
+ end
+
+ def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "auth"}}, state) do
+ spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, account.id}}], [:"$1"]}]
+ predicates = :dets.select(IRC.Account.file("predicates"), spec)
+ text = for {net, {key, value}} <- predicates, do: "#{net}: #{to_string(key)}: #{value}"
+ m.replyfun.(text)
+ {:noreply, state}
+ end
+
+ def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "whoami"}}, state) do
+ users = for user <- IRC.UserTrack.find_by_account(m.account) do
+ chans = Enum.map(user.privileges, fn({chan, _}) -> chan end)
+ |> Enum.join(" ")
+ "#{user.network} - #{user.nick}!#{user.username}@#{user.host} - #{chans}"
+ end
+ m.replyfun.(users)
+ {:noreply, state}
+ end
+
+ def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "account"}}, state) do
+ account = IRC.Account.lookup(m.sender)
+ text = ["Account Id: #{account.id}",
+ "Authenticate to this account from another network: \"auth #{account.id} #{account.token}\" to the other bot!"]
+ m.replyfun.(text)
+ {:noreply, state}
+ end
+
+ def handle_info({:irc, :text, m = %IRC.Message{sender: sender, text: "auth"<>_}}, state) do
+ #account = IRC.Account.lookup(m.sender)
+ case String.split(m.text, " ") do
+ ["auth", id, token] ->
+ join_account(m, id, token)
+ _ ->
+ m.replyfun.("Invalid parameters")
+ end
+ {:noreply, state}
+ end
+
+ def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "set-name "<>name}}, state) do
+ IRC.Account.update_account_name(account, name)
+ m.replyfun.("Name changed: #{name}")
+ {:noreply, state}
+ end
+
+ def handle_info({:irc, :text, m = %IRC.Message{text: "disable-sms"}}, state) do
+ if IRC.Account.get_meta(m.account, "sms-number") do
+ IRC.Account.delete_meta(m.account, "sms-number")
+ m.replfyun.("SMS disabled.")
+ else
+ m.replyfun.("SMS already disabled.")
+ end
+ {:noreply, state}
+ end
+
+ def handle_info({:irc, :text, m = %IRC.Message{text: "web"}}, state) do
+ auth_url = Untappd.auth_url()
+ login_url = LSG.AuthToken.new_url(m.account.id, nil)
+ m.replyfun.("-> " <> login_url)
+ {:noreply, state}
+ end
+
+ def handle_info({:irc, :text, m = %IRC.Message{text: "enable-sms"}}, state) do
+ code = String.downcase(EntropyString.small_id())
+ IRC.Account.put_meta(m.account, "sms-validation-code", code)
+ IRC.Account.put_meta(m.account, "sms-validation-target", m.network)
+ number = LSG.IRC.SmsPlugin.my_number()
+ text = "To enable or change your number for SMS messaging, please send:"
+ <> " \"enable #{code}\" to #{number}"
+ m.replyfun.(text)
+ {:noreply, state}
+ end
+
+ def handle_info({:irc, :text, m = %IRC.Message{text: "enable-telegram"}}, state) do
+ code = String.downcase(EntropyString.small_id())
+ IRC.Account.delete_meta(m.account, "telegram-id")
+ IRC.Account.put_meta(m.account, "telegram-validation-code", code)
+ IRC.Account.put_meta(m.account, "telegram-validation-target", m.network)
+ text = "To enable or change your number for telegram messaging, please open #{LSG.Telegram.my_path()} and send:"
+ <> " \"/enable #{code}\""
+ m.replyfun.(text)
+ {:noreply, state}
+ end
+
+ def handle_info({:irc, :text, m = %IRC.Message{text: "enable-untappd"}}, state) do
+ auth_url = Untappd.auth_url()
+ login_url = LSG.AuthToken.new_url(m.account.id, {:external_redirect, auth_url})
+ m.replyfun.(["To link your Untappd account, open this URL:", login_url])
+ {:noreply, state}
+ end
+
+ def handle_info({:irc, :text, m = %IRC.Message{text: "getmeta"<>_}}, state) do
+ result = case String.split(m.text, " ") do
+ ["getmeta"] ->
+ for {k, v} <- IRC.Account.get_all_meta(m.account) do
+ case k do
+ "u:"<>key -> "(user) #{key}: #{v}"
+ key -> "#{key}: #{v}"
+ end
+ end
+ ["getmeta", key] ->
+ value = IRC.Account.get_meta(m.account, key)
+ text = if value do
+ "#{key}: #{value}"
+ else
+ "#{key} is not defined"
+ end
+ _ ->
+ "usage: getmeta [key]"
+ end
+ m.replyfun.(result)
+ {:noreply, state}
+ end
+
+ def handle_info({:irc, :text, m = %IRC.Message{text: "setusermet"<>_}}, state) do
+ result = case String.split(m.text, " ") do
+ ["setusermeta", key, value] ->
+ IRC.Account.put_user_meta(m.account, key, value)
+ "ok"
+ _ ->
+ "usage: setusermeta <key> <value>"
+ end
+ m.replyfun.(result)
+ {:noreply, state}
+ end
+
+ def handle_info(_, state) do
+ {:noreply, state}
+ end
+
+ defp join_account(m, id, token) do
+ old_account = IRC.Account.lookup(m.sender)
+ new_account = IRC.Account.get(id)
+ if new_account && token == new_account.token do
+ case IRC.Account.merge_account(old_account.id, new_account.id) do
+ :ok ->
+ if old_account.id == new_account.id do
+ m.replyfun.("Already authenticated, but hello")
+ else
+ m.replyfun.("Accounts merged!")
+ end
+ _ -> m.replyfun.("Something failed :(")
+ end
+ else
+ m.replyfun.("Invalid token")
+ end
+ end
+
+ end
+
+end
diff --git a/lib/irc/connection.ex b/lib/irc/connection.ex
new file mode 100644
index 0000000..d115d88
--- /dev/null
+++ b/lib/irc/connection.ex
@@ -0,0 +1,462 @@
+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(LSG.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)
+ |> IO.inspect()
+ 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 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
+ backoff = :backoff.init(@min_backoff, @max_backoff)
+ |> :backoff.type(:jitter)
+ {:ok, %{client: nil, backoff: backoff, conn: conn}, {:continue, :connect}}
+ end
+
+ @triggers %{
+ "!" => :bang,
+ "+" => :plus,
+ "-" => :minus,
+ "?" => :query,
+ "." => :dot,
+ "~" => :tilde,
+ "++" => :plus_plus,
+ "--" => :minus_minus,
+ "!!" => :bang_bang,
+ "??" => :query_query,
+ }
+
+ 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)
+ client
+ end
+ ExIRC.Client.add_handler(client, self())
+ if state.conn.tls do
+ ExIRC.Client.connect_ssl!(client, state.conn.host, state.conn.port)
+ else
+ ExIRC.Client.connect!(client, state.conn.host, state.conn.port)
+ end
+ {: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.client, {channel, nil}, line)
+ {:noreply, state}
+ end
+
+ # Connection successful
+ def handle_info({:connected, server, port}, state) do
+ Logger.info("#{inspect(self())} Connected to #{server}:#{port} #{inspect state}")
+ ExIRC.Client.logon(state.client, state.conn.pass || "", state.conn.nick, state.conn.user, state.conn.name)
+ {:noreply, state}
+ 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
+
+ # 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
+ reply_fun = fn(text) -> irc_reply(state.client, {chan, sender}, text) end
+ account = IRC.Account.lookup(sender)
+ message = %IRC.Message{text: text, network: network(state), 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:#{chan}"])
+ {:noreply, state}
+ end
+
+ # Received a private message
+ def handle_info({:received, text, sender}, state) do
+ reply_fun = fn(text) -> irc_reply(state.client, {sender.nick, sender}, text) end
+ account = IRC.Account.lookup(sender)
+ message = %IRC.Message{text: text, network: network(state), 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, ["message:private"])
+ {: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.client, {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.client, {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)
+ {: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(network(state), channel, nick)
+ {:noreply, state}
+ end
+
+ def handle_info({:parted, channel, %ExIRC.SenderInfo{nick: nick}}, state) do
+ IRC.UserTrack.parted(channel, nick)
+ {:noreply, state}
+ end
+
+ def handle_info({:mode, [channel, mode, nick]}, state) do
+ track_mode(network(state), channel, nick, mode)
+ {:noreply, state}
+ end
+
+ def handle_info({:nick_changed, old_nick, new_nick}, state) do
+ IRC.UserTrack.renamed(network(state), old_nick, new_nick)
+ {:noreply, state}
+ end
+
+ def handle_info(unhandled, client) do
+ IO.puts inspect(unhandled)
+ {:noreply, client}
+ end
+
+ def publish(pub), do: publish(pub, [])
+
+ def publish(m = %IRC.Message{trigger: nil}, keys) do
+ dispatch(["message"] ++ keys, {:irc, :text, m})
+ end
+
+ def publish(m = %IRC.Message{trigger: t = %IRC.Trigger{trigger: trigger}}, keys) do
+ dispatch(["triggers", "trigger:"<>trigger], {:irc, :trigger, trigger, m})
+ 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
+ IO.puts "dispatching to #{inspect({sub,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(client, {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()
+ for line <- lines do
+ ExIRC.Client.msg(client, :privmsg, target, line)
+ end
+ end
+
+ defp irc_reply(client, {target, %{nick: nick}}, {:kick, reason}) do
+ ExIRC.Client.kick(client, target, nick, reason)
+ end
+
+ defp irc_reply(client, {target, _}, {:kick, nick, reason}) do
+ ExIRC.Client.kick(client, target, nick, reason)
+ end
+
+ defp irc_reply(client, {target, %{nick: nick}}, {:mode, mode}) do
+ ExIRC.Client.mode(client, target, mode, nick)
+ end
+
+ defp irc_reply(client, target, {:mode, mode, nick}) do
+ ExIRC.Client.mode(client, target, mode, nick)
+ end
+
+ defp irc_reply(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 server(%{conn: %{host: host, port: port}}) do
+ host <> ":" <> to_string(port)
+ end
+
+ defp network(state = %{conn: %{network: network}}) do
+ if network do
+ network
+ else
+ # FIXME performance meheeeee
+ ExIRC.Client.state(state.client)[:network]
+ end
+ end
+
+end
diff --git a/lib/irc/connection_handler.ex b/lib/irc/connection_handler.ex
deleted file mode 100644
index 1c335f2..0000000
--- a/lib/irc/connection_handler.ex
+++ /dev/null
@@ -1,36 +0,0 @@
-defmodule IRC.ConnectionHandler do
- defmodule State do
- defstruct [:host, :port, :pass, :nick, :name, :user, :client]
- end
-
- def start_link(client) do
- irc = Application.get_env(:lsg, :irc)[:irc]
- host = irc[:host]
- port = irc[:port]
- nick = irc[:nick]
- user = irc[:user]
- name = irc[:name]
- GenServer.start_link(__MODULE__, [%State{client: client, host: host, port: port, nick: nick, user: user, name: name}])
- end
-
- def init([state]) do
- ExIRC.Client.add_handler state.client, self
- ExIRC.Client.connect! state.client, state.host, state.port
- {:ok, state}
- end
-
- def handle_info({:connected, server, port}, state) do
- debug "Connected to #{server}:#{port}"
- ExIRC.Client.logon state.client, state.pass, state.nick, state.user, state.name
- {:noreply, state}
- end
-
- # Catch-all for messages you don't care about
- def handle_info(msg, state) do
- {:noreply, state}
- end
-
- defp debug(msg) do
- IO.puts IO.ANSI.yellow() <> msg <> IO.ANSI.reset()
- 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/login_handler.ex b/lib/irc/login_handler.ex
deleted file mode 100644
index 4b02dc2..0000000
--- a/lib/irc/login_handler.ex
+++ /dev/null
@@ -1,27 +0,0 @@
-defmodule IRC.LoginHandler do
- def start_link(client) do
- GenServer.start_link(__MODULE__, [client])
- end
-
- def init([client]) do
- ExIRC.Client.add_handler client, self
- conf = Application.get_env(:lsg, :irc, []) |> Keyword.get(:irc)
- channels = Keyword.get(conf, :channels, [])
- {:ok, {client, channels}}
- end
-
- def handle_info(:logged_in, state = {client, channels}) do
- debug "Logged in to server"
- channels |> Enum.map(&ExIRC.Client.join client, &1)
- {:noreply, state}
- end
-
- # Catch-all for messages you don't care about
- def handle_info(_msg, state) do
- {:noreply, state}
- end
-
- defp debug(msg) do
- IO.puts IO.ANSI.yellow() <> msg <> IO.ANSI.reset()
- end
-end
diff --git a/lib/irc/membership.ex b/lib/irc/membership.ex
new file mode 100644
index 0000000..74ed1b4
--- /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(LSG.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/plugin_supervisor.ex b/lib/irc/plugin_supervisor.ex
new file mode 100644
index 0000000..ca092dc
--- /dev/null
+++ b/lib/irc/plugin_supervisor.ex
@@ -0,0 +1,91 @@
+defmodule IRC.Plugin do
+ require Logger
+
+ defmodule Supervisor do
+ use DynamicSupervisor
+
+ def start_link() do
+ DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__)
+ end
+
+ def start_child(module, opts \\ []) do
+ IO.inspect(module)
+ spec = %{id: {IRC.Plugin,module}, start: {IRC.Plugin, :start_link, [module, opts]}, name: module, 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 dets(), do: to_charlist(LSG.data_path("/plugins.dets"))
+
+ def setup() do
+ :dets.open_file(dets(), [])
+ end
+
+ def enabled() do
+ :dets.foldl(fn
+ {name, true, _}, acc -> [name | acc]
+ _, acc -> acc
+ end, [], dets())
+ end
+
+ def start_all() do
+ for mod <- enabled(), do: {mod, IRC.Plugin.Supervisor.start_child(mod)}
+ end
+
+ def declare(module) do
+ case get(module) do
+ :disabled -> :dets.insert(dets(), {module, true, nil})
+ _ -> nil
+ end
+ end
+
+ def start(module, opts \\ []) do
+ IRC.Plugin.Supervisor.start_child(module)
+ end
+
+ @doc "Enables a plugin"
+ def enable(name), do: switch(name, true)
+
+ @doc "Disables a plugin"
+ def disable(name), do: switch(name, false)
+
+ @doc "Enables or disables a plugin"
+ def switch(name, value) when is_boolean(value) do
+ last = case get(name) do
+ {:ok, last} -> last
+ _ -> nil
+ end
+ :dets.insert(dets(), {name, value, last})
+ end
+
+ @spec get(module()) :: {:ok, last_start :: nil | non_neg_integer()} | :disabled
+ def get(name) do
+ case :dets.lookup(dets(), name) do
+ [{name, enabled, last_start}] -> {:ok, enabled, last_start}
+ _ -> :disabled
+ end
+ end
+
+ def start_link(module, options \\ []) do
+ with {:disabled, {_, true, last}} <- {:disabled, get(module)},
+ {:throttled, false} <- {:throttled, false}
+ do
+ module.start_link()
+ else
+ {error, _} ->
+ Logger.info("Plugin: #{to_string(module)} ignored start: #{to_string(error)}")
+ :ignore
+ end
+ end
+
+end
+
diff --git a/lib/irc/pubsub_handler.ex b/lib/irc/pubsub_handler.ex
deleted file mode 100644
index 61ae55c..0000000
--- a/lib/irc/pubsub_handler.ex
+++ /dev/null
@@ -1,135 +0,0 @@
-defmodule IRC.PubSubHandler do
- @moduledoc """
- # IRC PubSub
-
- Provides a nicer abstraction over ExIRC's handlers.
-
- ## PubSub topics
-
- * `message` -- all 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
-
- def start_link(client) do
- GenServer.start_link(__MODULE__, [client], [name: __MODULE__])
- end
-
- def privmsg(channel, line) do
- GenServer.cast(__MODULE__, {:privmsg, channel, line})
- end
-
- def init([client]) do
- ExIRC.Client.add_handler(client, self())
- {:ok, client}
- end
-
- @triggers %{
- "!" => :bang,
- "+" => :plus,
- "-" => :minus,
- "?" => :query,
- "." => :dot,
- "~" => :tilde
- }
-
- def handle_cast({:privmsg, channel, line}, client) do
- irc_reply(client, {channel, nil}, line)
- {:noreply, client}
- end
-
- def handle_info({:received, text, sender, chan}, client) do
- reply_fun = fn(text) -> irc_reply(client, {chan, sender}, text) end
- message = %IRC.Message{text: text, sender: sender, channel: chan, replyfun: reply_fun, trigger: extract_trigger(text)}
- publish(message, ["message:#{chan}"])
- {:noreply, client}
- end
-
- def handle_info({:received, text, sender}, client) do
- reply_fun = fn(text) -> irc_reply(client, {sender.nick, sender}, text) end
- message = %IRC.Message{text: text, sender: sender, replyfun: reply_fun, trigger: extract_trigger(text)}
- publish(message, ["message:private"])
- {:noreply, client}
- end
-
- def handle_info(unhandled, client) do
- IO.puts inspect(unhandled)
- {:noreply, client}
- end
-
- defp publish(pub), do: publish(pub, [])
-
- defp publish(m = %IRC.Message{trigger: nil}, keys) do
- dispatch(["message"] ++ keys, {:irc, :text, m})
- end
-
- defp publish(m = %IRC.Message{trigger: t = %IRC.Trigger{trigger: trigger}}, keys) do
- dispatch(["triggers", "trigger:"<>trigger], {:irc, :trigger, trigger, m})
- end
-
- defp dispatch(key, content) when is_binary(key), do: dispatch([key], content)
- defp dispatch(keys, content) when is_list(keys) do
- IO.puts "dispatching to #{inspect(keys)} --> #{inspect content}"
- for key <- keys do
- spawn(fn() -> Registry.dispatch(IRC.PubSub, key, fn h ->
- for {pid, _} <- h, do: send(pid, content)
- end) end)
- end
- end
-
- #
- # Triggers
- #
-
-
- for {trigger, name} <- @triggers do
- defp extract_trigger(unquote(trigger)<>text) do
- text = String.strip(text)
- [trigger | args] = String.split(text, " ")
- %IRC.Trigger{type: unquote(name), trigger: trigger, args: args}
- end
- end
-
- defp 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(client, {target, _}, text) when is_binary(text) or is_list(text) do
- for line <- IRC.splitlong(text) do
- ExIRC.Client.msg(client, :privmsg, target, line)
- end
- end
-
- defp irc_reply(client, {target, %{nick: nick}}, {:kick, reason}) do
- ExIRC.Client.kick(client, target, nick, reason)
- end
-
- defp irc_reply(client, {target, _}, {:kick, nick, reason}) do
- ExIRC.Client.kick(client, target, nick, reason)
- end
-
- defp irc_reply(client, {target, %{nick: nick}}, {:mode, mode}) do
- ExIRC.Client.mode(client, target, mode, nick)
- end
-
- defp irc_reply(client, target, {:mode, mode, nick}) do
- ExIRC.Client.mode(client, target, mode, nick)
- end
-
- defp irc_reply(client, target, {:channel_mode, mode}) do
- ExIRC.Client.mode(client, target, mode)
- end
-
-end
diff --git a/lib/irc/user_track.ex b/lib/irc/user_track.ex
index 02d5752..08b5160 100644
--- a/lib/irc/user_track.ex
+++ b/lib/irc/user_track.ex
@@ -4,7 +4,7 @@ defmodule IRC.UserTrack do
"""
@ets IRC.UserTrack.Storage
- # {uuid, nick, nicks, privilege_map}
+ # {uuid, network, nick, nicks, privilege_map}
# Privilege map:
# %{"#channel" => [:operator, :voice]
defmodule Storage do
@@ -49,24 +49,87 @@ defmodule IRC.UserTrack do
defmodule Id, do: use EntropyString
defmodule User do
- defstruct [:id, :nick, :nicks, :username, :host, :realname, :privileges]
+ defstruct [:id, :account, :network, :nick, {:nicks, []}, :username, :host, :realname, {:privileges, %{}}, {:last_active, %{}}]
def to_tuple(u = %__MODULE__{}) do
- {u.id || IRC.UserTrack.Id.large_id, u.nick, u.nicks || [], u.username, u.host, u.realname, u.privileges}
+ {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}
end
- def from_tuple({id, nick, nicks, username, host, realname, privs}) do
- %__MODULE__{id: id, nick: nick, nicks: nicks, username: username, realname: realname, privileges: privs}
+ #tuple size: 11
+ def from_tuple({id, network, account, _downcased_nick, nick, nicks, username, host, realname, privs, last_active}) 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}
end
end
- def find_by_nick(nick) do
- case :ets.match(@ets, {:'$1', nick, :_, :_, :_, :_, :_}) do
- [[id]] -> lookup(id)
+ 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}}
+ ], [:"$_"]}
+ ]
+ for obj <- :ets.select(@ets, spec), 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 | _] ->
+ results
+ |> Enum.sort_by(fn({_, _, _, _, _, _, _, _, _, _, actives}) ->
+ Map.get(actives, nil)
+ end, {:desc, NaiveDateTime})
+ |> List.first
+ |> User.from_tuple()
_ -> nil
end
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}) ->
+ Storage.op(fn(ets) ->
+ :ets.insert(@ets, {id, net, new_id, downcased_nick, nick, nicks, username, host, realname, privs, active})
+ 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
@@ -76,8 +139,8 @@ defmodule IRC.UserTrack do
end
end
- def operator?(channel, nick) do
- if user = find_by_nick(nick) do
+ 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
@@ -85,22 +148,34 @@ defmodule IRC.UserTrack do
end
end
- def channel(channel) do
- Enum.filter(to_list(), fn({_, nick, _, _, _, _, channels}) ->
+ def channel(network, channel) do
+ Enum.filter(to_list(), fn({_, network, _, _, _, _, _, _, _, channels, _}) ->
Map.get(channels, channel)
end)
end
+ # TODO
+ def connected(sender = %{nick: nick}) do
+ end
+
def joined(c, s), do: joined(c,s,[])
- def joined(channel, sender=%{nick: nick, user: uname, host: host}, privileges) do
+ 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(nick) do
+ 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{nick: nick, username: uname, host: host, privileges: %{channel => privileges}}
+ user = %User{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) ->
@@ -108,27 +183,47 @@ defmodule IRC.UserTrack do
end)
end
- def joined(channel, nick, privileges) do
- user = if user = find_by_nick(nick) do
- %User{user | privileges: Map.put(user.privileges, channel, privileges)}
+ #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{nick: nick, privileges: %{channel => privileges}}
+ 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
-
- Storage.op(fn(ets) ->
- :ets.insert(ets, User.to_tuple(user))
- end)
end
- def renamed(old_nick, new_nick) do
- if user = find_by_nick(old_nick) do
+ 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))
end
end
- def change_privileges(channel, nick, {add, remove}) do
- if user = find_by_nick(nick) do
+ 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)
@@ -139,9 +234,18 @@ defmodule IRC.UserTrack do
end
end
- def parted(channel, nick) do
- if user = find_by_nick(nick) do
+ 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))
@@ -152,9 +256,20 @@ defmodule IRC.UserTrack do
end
def quitted(sender) do
- if user = find_by_nick(sender.nick) do
- Storage.delete(user.id)
+ 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
+ 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
+
end
diff --git a/lib/irc/user_track_handler.ex b/lib/irc/user_track_handler.ex
deleted file mode 100644
index 0ae802a..0000000
--- a/lib/irc/user_track_handler.ex
+++ /dev/null
@@ -1,93 +0,0 @@
-defmodule IRC.UserTrackHandler do
- @moduledoc """
- # User Track Handler
-
- This handlers keeps track of users presence and privileges.
-
- Planned API:
-
- UserTrackHandler.operator?(%ExIRC.Sender{nick: "href", …}, "#channel") :: boolean
-
- """
-
- def irc_doc, do: nil
-
- def start_link(client) do
- GenServer.start_link(__MODULE__, [client])
- end
-
- defstruct client: nil, ets: nil
-
- def init([client]) do
- ExIRC.Client.add_handler client, self
- {:ok, %__MODULE__{client: client}}
- end
-
- def handle_info({:joined, channel}, state) do
- ExIRC.Client.who(state.client, channel)
- {:noreply, state}
- end
-
- def handle_info({:who, channel, whos}, state) do
- Enum.map(whos, fn(who = %ExIRC.Who{nick: nick, operator?: operator}) ->
- priv = if operator, do: [:operator], else: []
- IRC.UserTrack.joined(channel, who, priv)
- end)
- {:noreply, state}
- end
-
- def handle_info({:quit, _reason, sender}, state) do
- IRC.UserTrack.quitted(sender)
- {:noreply, state}
- end
-
- def handle_info({:joined, channel, sender}, state) do
- IRC.UserTrack.joined(channel, sender, [])
- {:noreply, state}
- end
-
- def handle_info({:kicked, nick, _by, channel, _reason}, state) do
- parted(channel, nick)
- {:noreply, state}
- end
-
- def handle_info({:parted, channel, %ExIRC.SenderInfo{nick: nick}}, state) do
- parted(channel, nick)
- {:noreply, state}
- end
-
- def handle_info({:mode, [channel, mode, nick]}, state) do
- mode(channel, nick, mode)
- {:noreply, state}
- end
-
- def handle_info({:nick_changed, old_nick, new_nick}, state) do
- rename(old_nick, new_nick)
- {:noreply, state}
- end
-
- def handle_info(msg, state) do
- {:noreply, state}
- end
-
- defp parted(channel, nick) do
- IRC.UserTrack.parted(channel, nick)
- :ok
- end
-
- defp mode(channel, nick, "+o") do
- IRC.UserTrack.change_privileges(channel, nick, {[:operator], []})
- :ok
- end
-
- defp mode(channel, nick, "-o") do
- IRC.UserTrack.change_privileges(channel, nick, {[], [:operator]})
- :ok
- end
-
- defp rename(old, new) do
- IRC.UserTrack.renamed(old, new)
- :ok
- end
-
-end