diff options
Diffstat (limited to 'lib/irc/connection.ex')
-rw-r--r-- | lib/irc/connection.ex | 462 |
1 files changed, 462 insertions, 0 deletions
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 |