summaryrefslogtreecommitdiff
path: root/lib/irc/connection.ex
diff options
context:
space:
mode:
Diffstat (limited to 'lib/irc/connection.ex')
-rw-r--r--lib/irc/connection.ex521
1 files changed, 521 insertions, 0 deletions
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