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
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: nil}, {: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
if state.conn.tls do
ExIRC.Client.connect_ssl!(client, state.conn.host, state.conn.port, [])#[{:ifaddr, {45,150,150,33}}])
else
ExIRC.Client.connect!(client, state.conn.host, state.conn.port, [])#[{:ifaddr, {45,150,150,33}}])
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, {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}")
{_, 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) do
IRC.UserTrack.clear_network(network)
{:noreply, %{state | network: network}}
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, {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}", "#{message.network}/#{chan}:message"])
{: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{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, {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)
{: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(network(state), 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
Logger.debug("unhandled: #{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", "#{m.network}/#{m.channel}: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: 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()
for line <- lines do
ExIRC.Client.msg(client, :privmsg, target, line)
end
case :global.whereis_name({LSG.TelegramRoom, network, target}) do
pid when is_pid(pid) -> send(pid, {:raw, text})
_ -> :ok
end
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
defp network(state = %{conn: %{network: network}}) do
if network do
network
else
# FIXME performance meheeeee
ExIRC.Client.state(state.client)[:network]
end
end
end