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.ex928
1 files changed, 928 insertions, 0 deletions
diff --git a/lib/irc/connection.ex b/lib/irc/connection.ex
new file mode 100644
index 0000000..f43fca7
--- /dev/null
+++ b/lib/irc/connection.ex
@@ -0,0 +1,928 @@
+defmodule Irc.Connection do
+ @behaviour :gen_statem
+ require Logger
+ alias Irc.Parser.Line
+ require Irc.Parser.Numeric
+ import Irc.Parser.Numeric
+
+ @backoff_min :timer.seconds(5)
+ @backoff_max :timer.minutes(15)
+ @connect_timeout :timer.minutes(1)
+ @reply_timeout :timer.seconds(1)
+ @cap_timeout :timer.seconds(15)
+ @registration_timeout :timer.seconds(30)
+ @support_capabs ["sasl", "cap-notify"]
+ @internal {:next_event, :internal, nil}
+
+ defstruct [:server, :nick, :modes, :capabs, :server_version, :server_umodes, :server_cmodes, :server_cmodes_with_params, :isupport, :motd]
+
+ @typedoc "Connection Information state"
+ @type t :: %__MODULE__{
+ server: String.t,
+ nick: String.t,
+ capabs: list(),
+ modes: String.t,
+ server_version: String.t,
+ server_umodes: String.t,
+ server_cmodes: String.t,
+ server_cmodes_with_params: String.t,
+ isupport: isupport(),
+ motd: [String.t],
+ }
+
+ @typedoc "Server ISUPPORT"
+ @type isupport :: %{String.t() => true | nil | String.t | %{String.t() => any()}}
+
+ @typedoc "Messages sent to the owning process."
+ @type message :: msg_up() | msg_down() | msg_error()
+ | msg_line() | msg_umodes() | msg_nick()
+
+ @typedoc "Connection is now up and registered."
+ @type msg_up :: {:irc_conn_up, pid(), t()}
+ @typedoc "Message: Received an IRC line."
+ @type msg_line :: {:irc_conn_line, pid(), Line.t}
+ @typedoc "Message: Connection user modes have changed."
+ @type msg_umodes :: {:irc_conn_modes, pid(), changes :: String.t, previous_modes :: list(), modes :: list()}
+ @typedoc "Message: Connection nickname have changed."
+ @type msg_nick :: {:irc_conn_nick, pid(), previous_nick :: String.t, new_nick :: String.t}
+ @typedoc "Message: Connection is down and will try to reconnect in `delay`."
+ @type msg_down :: {:irc_conn_down, pid(), error, delay :: integer}
+ @typedoc "Message: Connection encountered a fatal error and stopped."
+ @type msg_error :: {:irc_conn_error, pid(), error}
+
+ @typedoc "Connection error reasons"
+ @type error :: {:error, error_irc | any}
+
+ @typedoc "IRC Error reasons"
+ @type error_irc :: {:killed, by :: String.t, reason :: String.t}
+ | {:quit, reason :: String.t}
+ | {:irc_error, reason :: String.t}
+
+ @typedoc """
+ Start options.
+
+ * user: IRC user,
+ * name: IRC real name,
+ * pass: IRC server password,
+ * capabs: IRCv3 capabs to request (if available),
+ * port,
+ * tls,
+ * reconnect,
+ * process,
+ * tcp_opts and tls_opts.
+ """
+ @type start_opt :: {:user, nil | String.t}
+ | {:name, nil | String.t}
+ | {:pass, nil | String.t}
+ | {:capabs, [String.t]}
+ | {:port, 6667 | Integer.t}
+ | {:tls, false | true}
+ | {:inet, nil | 4 | 6}
+ | {:iface, nil | String.t}
+ | {:ip, nil | String.t}
+ | {:reconnect, true | false}
+ | {:process, pid}
+ | {:tcp_opts, [:gen_tcp.connect_option]}
+ | {:tls_opts, [:ssl.connect_option]}
+
+ @moduledoc """
+ Lightweight implementation of an IRC Connection handling only the basics:
+
+ * the socket and reconnections,
+ * registration process,
+ * sasl authentication,
+ * capabilities negotiation,
+ * ping,
+ * nick and user mode changes.
+
+ Events and lines are sent to calling PID.
+
+ ## Usage
+
+ The client is started using `start/4` or `start_link/4`:
+
+ ```elixir
+ {:ok, Pid} = Irc.Connection.start("ExIrcTest", "irc.random.sh", [tls: true, port: 6697], [])
+ ```
+
+ Messages will be sent to your PID, see `t:message/0` for the possible types.
+
+ You can send lines using `sendline/3`, and disconnect with `disconnect/3`.
+
+ Status and information about the connection (`t:t/0`) can be requested with `info/1`.
+
+ If you are looking for a more featured client, you probably want `Irc.Client`!
+ """
+
+ @type nick :: String.t
+ @type host :: String.t
+
+ @type start_ret :: {:ok, pid} | {:error, error} | :gen_statem.start_ret
+
+ @spec start(nick, host, [start_opt], [:gen_statem.start_opt]) :: start_ret
+ @doc """
+ Start a connection.
+ """
+ def start(nick, host, opts \\ [], start_opts \\ []) do
+ :gen_statem.start(__MODULE__, [prepare_args(nick, host, opts)], start_opts)
+ end
+
+ @spec start_link(nick, host, [start_opt], [:gen_statem.start_opt]) :: start_ret
+ @doc """
+ Start and link a connection.
+ """
+ def start_link(nick, host, opts \\ [], start_opts \\ []) do
+ :gen_statem.start_link(__MODULE__, [prepare_args(nick, host, opts)], start_opts)
+ end
+
+
+ @spec reconnect(pid) :: :ok | {:error, :not_disconnected}
+ @doc """
+ Reconnects the connection.
+
+ Can only be used when the connection is disconnected. This will override either a `disconnect/3`, or re-connect early after an error
+ """
+ def reconnect(pid) do
+ :gen_statem.call(pid, :reconnect)
+ end
+
+ @spec disconnect(pid, quit_message :: String.t, stop :: boolean) :: :ok | {:error, :not_connected} | {:error, :noproc}
+ @doc """
+ Disconnects.
+
+ If `stop` is set to false (default true), the connection PID will keep running and can be reconnected with `reconnect/1`
+ """
+ def disconnect(pid, quit_message \\ nil, stop \\ true) do
+ :gen_statem.call(pid, {:disconnect, stop, quit_message})
+ catch
+ :exit, {:noproc, _} -> {:error, :noproc}
+ end
+
+ @spec info(pid) :: {:connected, t()} | :connecting | :disconnected
+ @doc "Returns the connection state and info."
+ def info(pid) do
+ :gen_statem.call(pid, :info)
+ end
+
+ @spec sendline(pid, iolist, sync :: boolean) :: :ok | {:error, :not_connected} | msg_error
+ @doc "Sends a line over the connection."
+ def sendline(pid, data, sync \\ true) do
+ fun = if sync, do: :call, else: :cast
+ apply(:gen_statem, fun, [pid, {:send, data}])
+ end
+
+ @doc "Wait until the connection is registered."
+ @spec await_up(pid, non_neg_integer | :infinity) :: {:ok, t} | msg_error | {:error, :await_up_timeout}
+ def await_up(pid, timeout \\ :infinity) do
+ mon = Process.monitor(pid)
+ result = receive do
+ {:irc_conn_up, pid, info} -> {:ok, info}
+ {:irc_conn_error, pid, error} -> {:error, error}
+ {:DOWN, _, :process, pid, _} -> {:error, :down}
+ after
+ timeout -> {:error, :await_up_timeout}
+ end
+ flush(pid)
+ Process.demonitor(mon)
+ result
+ end
+
+ @doc "Flush all messages from the connection."
+ def flush(pid) do
+ receive do
+ {:irc_conn_up, pid, _} -> flush(pid)
+ {:irc_conn_line, pid, _} -> flush(pid)
+ {:irc_conn_modes, pid, _, _, _} -> flush(pid)
+ {:irc_conn_nick, pid, _, _} -> flush(pid)
+ {:irc_conn_down, pid, _, _} -> flush(pid)
+ {:irc_conn_error, pid, _} -> flush(pid)
+ after
+ 0 -> :ok
+ end
+ end
+
+ defmodule State do
+ @moduledoc false
+ defstruct [:args,
+ :backoff, :socket, :sockmodule, {:tries, 0},
+ :process, :monitor,
+ :nick,
+ :server,
+ :server_version,
+ :server_umodes,
+ :server_cmodes,
+ :server_cmodes_with_params,
+ :welcomed,
+ {:errored, false},
+ {:isupport, %{}},
+ {:modes, []},
+ {:server_capabs, []},
+ {:req_capabs, []},
+ {:capabs, []},
+ {:buffer, []},
+ {:motd, []},
+ {:welcome, []},
+ ]
+ end
+
+ @doc false
+ def callback_mode, do: [:state_functions, :state_enter]
+
+ @doc false
+ def init([args]) do
+ backoff_min = Keyword.get(args, :backoff_min, @backoff_min)
+ backoff_max = Keyword.get(args, :backoff_max, @backoff_max)
+ process = Keyword.get(args, :process, nil)
+ monitor = Process.monitor(process)
+ with \
+ {:ok, processed_args} <- process_args(args),
+ backoff <- :backoff.init(backoff_min, backoff_max)
+ do
+ data = %State{
+ args: processed_args,
+ process: process,
+ monitor: monitor,
+ backoff: backoff
+ }
+ {:ok, :connect, data, @internal}
+ else
+ {:error, error} ->
+ {:stop, error}
+ end
+ end
+
+ # TCP Open
+
+ @doc false
+ def connect(:internal, _, data = %{args: %{tls: true}}) do
+ {:next_state, :connect_tls, data, {:next_event, :internal, nil}}
+ end
+
+ def connect(:internal, _, data) do
+ Logger.debug("#{inspect(data)} connecting...")
+ options = data.args.bind ++ data.args.inet ++ [{:active, true}, {:packet, :line}] ++ data.args.tcp_opts
+ case :gen_tcp.connect(String.to_charlist(data.args.host), data.args.port, options) do
+ {:ok, socket} ->
+ {_, backoff} = :backoff.succeed(data.backoff)
+ {:next_state, :connected, %State{data | backoff: backoff}, {:next_event, :internal, {:gen_tcp, socket}}}
+ error ->
+ # TODO ERRORS
+ to_disconnected(error, data)
+ end
+ end
+
+ def connect(type, content, data) do
+ handle_common(:connect, type, content, data)
+ end
+
+ # TLS Connect
+
+ @doc false
+ def connect_tls(:internal, nil, data) do
+ options = data.args.bind ++ data.args.inet ++ [{:active, true}, {:packet, :line}] ++ data.args.tcp_opts ++ data.args.tls_opts
+ case :ssl.connect(String.to_charlist(data.args.host), data.args.port, options) do
+ {:ok, socket} ->
+ {_, backoff} = :backoff.succeed(data.backoff)
+ {:next_state, :connected, %State{data | backoff: backoff}, {:next_event, :internal, {:ssl, socket}}}
+ error ->
+ to_disconnected(error, data)
+ end
+ end
+
+ def connect_tls(type, content, data) do
+ handle_common(:connect_tls, type, content, data)
+ end
+
+ @doc false
+ def connected(:internal, {module, socket}, data) do
+ {:next_state, :cap_ls, %State{data | sockmodule: module, socket: socket}, @internal}
+ end
+
+ def connected(type, content, data) do
+ handle_common(:connected, type, content, data)
+ end
+
+ # Registration: LIST CAPABS
+
+ @doc false
+ def cap_ls(:internal, _, data) do
+ case socksend(data, 'CAP LS 320') do
+ :ok -> {:keep_state_and_data, {:state_timeout, @cap_timeout, nil}}
+ error ->
+ Logger.error("Disconnected in socksend")
+ to_disconnected(error, data)
+ end
+ end
+
+ def cap_ls(:info, msg = {module, socket, line}, data) do
+ case Line.parse(line) do
+ l = %Line{command: "CAP", args: [_, "LS", "*", rest]} ->
+ capabs = String.split(rest, " ")
+ {:keep_state, %State{data | server_capabs: data.capabs ++ capabs}, {:state_timeout, @reply_timeout, nil}}
+ l = %Line{command: "CAP", args: ["*", "LS", rest]} ->
+ capabs = Enum.uniq(data.capabs ++ String.split(rest, " "))
+ {:next_state, :reg_pass, %State{data | server_capabs: capabs}, @internal}
+ # TODO: What do?
+ l ->
+ handle_common(:cap_ls, :info, msg, data)
+ # {:next_state, :registration, data, {:next_event, :internal, nil}}
+ end
+ end
+
+ def cap_ls(:state_timeout, _, data) do
+ capabs = Enum.uniq(data.capabs)
+ Logger.debug("State timeout")
+ {:next_state, :reg_pass, %State{data | server_capabs: capabs}, @internal}
+ end
+
+ def cap_ls(type, content, data) do
+ Logger.debug("Handling common #{inspect({type,content,data})}")
+ handle_common(:cap_ls, type, content, data)
+ end
+
+ # Registration: PASS
+
+ @doc false
+ def reg_pass(:internal, _, data = %State{args: %{pass: pass}}) when is_binary(pass) and pass != "" do
+ case socksend(data, ['PASS ', pass]) do
+ :ok ->
+ {:keep_state_and_data, {:state_timeout, @reply_timeout, nil}}
+ error ->
+ to_disconnected(error, data)
+ end
+ end
+
+ def reg_pass(:internal, _, data) do
+ {:next_state, :reg_nick, data, @internal}
+ end
+
+ def reg_pass(:info, content = {_, _, line}, data) do
+ case Line.parse(line) do
+ %Line{command: err_PASSWDMISMATCH()} ->
+ {:next_state, :error, {:invalid_password, data.args.pass}}
+ line ->
+ #{:keep_state_and_data, [:postpone, {:state_timeout, @reply_timeout, nil}]}
+ handle_common(:reg_pass, :info, content, data)
+ end
+ end
+
+ def reg_pass(:state_timeout, _, data) do
+ {:next_state, :reg_nick, data, @internal}
+ end
+
+ def reg_pass(type, content, data) do
+ handle_common(:reg_pass, type, content, data)
+ end
+
+ # --- Registration: NICKNAME
+
+ @doc false
+ def reg_nick(:internal, nick, data) do
+ case socksend(data, ['NICK ', nick || data.args.nick]) do
+ :ok -> {:keep_state, %State{data | nick: nick}, {:state_timeout, @reply_timeout, nil}}
+ error -> to_disconnected(error, data)
+ end
+ end
+
+ def reg_nick(:info, content = {_, _, line}, data) do
+ case Line.parse(line) do
+ %Line{command: cmd} when cmd in [err_NICKNAMEINUSE(), err_NICKCOLLISION()] ->
+ new_nick = data.args.nick <> to_string(:random.uniform(9999))
+ {:keep_state, data, {:next_event, :internal, new_nick}}
+ line = %Line{command: cmd} when cmd in [err_ERRONEUSNICKNAME(), err_NONICKNAMEGIVEN()] ->
+ {:next_state, :error, data, {:next_event, :internal, {:invalid_nickname, data.args.nick, line}}}
+ line ->
+ #{:keep_state_and_data, :postpone}
+ handle_common(:reg_nick, :info, content, data)
+ end
+ end
+
+ def reg_nick(:state_timeout, _, data) do
+ {:next_state, :reg_user, data, @internal}
+ end
+
+ def reg_nick(type, content, data) do
+ handle_common(:reg_nick, type, content, data)
+ end
+
+ # --- Registration: USER
+
+ @doc false
+ def reg_user(:internal, _, data) do
+ user = List.flatten(['USER ', data.args.user, ' 0 * :', data.args.name])
+ case socksend(data, user) do
+ :ok ->
+ {:next_state, :cap_req, data, @internal}
+ error ->
+ to_disconnected(error, data)
+ end
+ end
+
+ #def reg_user(:state_timeout, _, data) do
+ # {:next_state, :welcome, data, {:state_timeout, @registration_timeout, nil}}
+ #end
+
+ def reg_user(type, content, data) do
+ handle_common(:reg_user, type, content, data)
+ end
+
+ @doc false
+ def cap_req(:internal, _, data = %{server_capabs: []}) do
+ {:next_state, :welcome, data}
+ end
+
+ def cap_req(:internal, _, data) do
+ capabs = data.args.capabs ++ @support_capabs
+ req = Enum.filter(@support_capabs, fn(capab) -> Enum.member?(data.server_capabs, capab) end)
+ |> Enum.uniq()
+ |> Enum.join(" ")
+ case socksend(data, ['CAP REQ :', String.to_charlist(req)]) do
+ :ok -> {:next_state, :cap_wait_ack, data}
+ error -> to_disconnected(error, data)
+ end
+ end
+
+ def cap_req(type, content, data) do
+ handle_common(:cap_req, type, content, data)
+ end
+
+ @doc false
+ def cap_wait_ack(:enter, _, _) do
+ {:keep_state_and_data, {:state_timeout, @reply_timeout, nil}}
+ end
+
+ # FIXME: Support multiline ACK/NACK
+ def cap_wait_ack(:info, content = {_, _, line}, data) do
+ case Line.parse(line) do
+ %Line{command: "CAP", args: [_, "ACK", capabs]} ->
+ capabs = String.split(capabs, " ")
+ {:keep_state, %State{data | capabs: capabs}, {:next_event, :internal, :end}}
+ %Line{command: "CAP", args: [_, "NACK", capabs]} ->
+ capabs = String.split(capabs, " ")
+ {:keep_state, %State{data | capabs: data.capabs -- capabs}, {:next_event, :internal, :end}}
+ line ->
+ handle_common(:cap_wait_ack, :info, content, data)
+ end
+ end
+
+ def cap_wait_ack(:internal, :end, data) do
+ case socksend(data, 'CAP END') do
+ :ok -> {:next_state, :welcome, data}
+ error -> to_disconnected(error, data)
+ end
+ end
+
+ # --- REGISTRATION: Welcome
+
+ @doc false
+ def welcome(:enter, _, data) do
+ {:keep_state_and_data, {:state_timeout, @registration_timeout, nil}}
+ end
+
+ @welcome_numeric [rpl_WELCOME, rpl_YOURHOST, rpl_CREATED, rpl_MYINFO, rpl_ISUPPORT]
+ def welcome(:info, content = {module, socket, line}, data) do
+ case Line.parse(line) do
+ l = %Line{command: err_NOMOTD()} ->
+ {:next_state, :registered, %State{data | motd: []}}
+ l = %Line{command: rpl_MOTDSTART()} ->
+ {:next_state, :motd, data, [:postpone]}
+ l = %Line{command: rpl_WELCOME(), source: source, args: [nick, _]} ->
+ {:keep_state, %State{data | nick: nick, welcome: [l | data.welcome], server: source}, {:state_timeout, @reply_timeout, nil}}
+ l = %Line{command: rpl_MYINFO(), source: source, args: [_, _, version, umodes, cmodes | rest]} ->
+ cmodes_with_params = case rest do
+ [] -> nil
+ [c] -> c
+ end
+ data = %State{data | welcome: [l | data.welcome], server_version: version, server_umodes: umodes,
+ server_cmodes: cmodes, server_cmodes_with_params: cmodes_with_params}
+ {:keep_state, data, {:state_timeout, @reply_timeout, nil}}
+ l = %Line{command: rpl_ISUPPORT(), args: [_ | isupport]} ->
+ supports = Irc.Parser.Isupport.parse(isupport, data.isupport)
+ {:keep_state, %State{data | isupport: supports}, {:state_timeout, @reply_timeout, nil}}
+ l = %Line{command: cmd} ->
+ #{:keep_state, %State{data | welcome: [l | data.welcome]}, {:state_timeout, @reply_timeout, nil}}
+ handle_common(:welcome, :info, content, data)
+ end
+ end
+
+ def welcome(:state_timeout, _, data) do
+ to_disconnected(:registration_timeout, data)
+ end
+
+ def welcome(type, content, data) do
+ handle_common(:welcome, type, content, data)
+ end
+
+ # --- Registration: MOTD
+
+ @doc false
+ def motd(:enter, _, data) do
+ {:keep_state_and_data, {:state_timeout, @reply_timeout, nil}}
+ end
+
+ def motd(:info, {_, _, line}, data) do
+ case Line.parse(line) do
+ %Line{command: rpl_MOTDSTART(), args: [_, line]} ->
+ {:keep_state, %State{data | motd: [line]}, {:state_timeout, @reply_timeout, nil}}
+ %Line{command: err_NOMOTD()} ->
+ {:next_state, :registered, %State{data | motd: []}}
+ %Line{command: rpl_MOTD(), args: [_, line]} ->
+ {:keep_state, %State{data | motd: [line | data.motd]}, {:state_timeout, @reply_timeout, nil}}
+ %Line{command: rpl_ENDOFMOTD(), args: [_, line]} ->
+ {:next_state, :registered, %State{data | motd: [line | data.motd]}}
+ line ->
+ {:keep_state_and_data, :postpone}
+ end
+ end
+
+ def motd(:state_timeout, _, data) do
+ to_disconnected(:motd_timeout, data)
+ end
+
+ def motd(type, content, data) do
+ handle_common(:motd, type, content, data)
+ end
+
+ # --- Registered
+
+ defp to_info(data, state \\ nil) do
+ data
+ |> Map.from_struct()
+ |> Map.take([:nick, :modes, :server, :server_version, :server_umodes, :server_cmodes, :server_cmodes_with_params, :capabs, :isupport, :motd])
+ |> Map.put(:__struct__, __MODULE__)
+ end
+
+ @conn_ping_timeout {:state_timeout, :timer.seconds(30), :ping}
+ @conn_pong_timeout {:state_timeout, :timer.seconds(30), :pong}
+
+ @doc false
+ def registered(:enter, _, data) do
+ data = %State{data | welcome: Enum.reverse(data.welcome),
+ motd: Enum.reverse(data.motd),
+ welcomed: true
+ }
+ msg = {:irc_conn_up, self(), to_info(data, :registered)}
+ send(data.process, msg)
+ Logger.info("#{inspect(data)} connected and registered")
+ {:next_state, :registered, data, @conn_ping_timeout}
+ end
+
+ def registered(:info, {_, _, line}, data) do
+ line = Line.parse(line)
+ Logger.debug "#{inspect(data)} <<< #{inspect(line)}"
+ case process_line(line, data) do
+ :ignore ->
+ msg = {:irc_conn_line, self(), line}
+ send(data.process, msg)
+ :keep_state_and_data
+ return ->
+ return
+ end
+ end
+
+ def registered({:call, ref}, {:send, line}, data) do
+ case socksend(data, line) do
+ :ok -> {:keep_state_and_data, [{:reply, ref, :ok}, @conn_ping_timeout]}
+ error -> {:next_state, :disconnected, data, [{:reply, :ref, {:error, error}}, {:next_event, :internal, error}]}
+ end
+ end
+
+ def registered(:cast, {:send, line}, data) do
+ case socksend(data, line) do
+ :ok -> {:keep_state_and_data, @conn_ping_timeout}
+ error -> to_disconnected(error, data)
+ end
+ end
+
+ def registered(:state_timeout, :ping, data) do
+ case socksend(data, [?P, ?I, ?N, ?G, 0x20, String.to_charlist(data.server)]) do
+ :ok -> {:keep_state_and_data, @conn_pong_timeout}
+ error -> to_disconnected(error, data)
+ end
+ end
+
+ def registered({:call, ref}, :info, data) do
+ {:keep_state_and_data, {:reply, ref, {:connected, to_info(data)}}}
+ end
+
+ def registered(:state_timeout, :pong, data) do
+ to_disconnected({:irc_error, {:timeout, :pang}}, data)
+ end
+
+ def registered(type, content, data) do
+ handle_common(:registered, type, content, data)
+ end
+
+ @doc false
+ def process_line(%Line{command: "KILL", source: source, args: [nick, reason]}, data = %{nick: nick}) do
+ to_disconnected({:killed, source, reason}, data)
+ end
+
+ def process_line(%Line{command: "QUIT", source: source, args: [reason]}, data = %{nick: nick}) do
+ if String.starts_with?(source, nick<>"!") do
+ to_disconnected({:quit, source, reason}, data)
+ else
+ :ignore
+ end
+ end
+
+ def process_line(%Line{command: "ERROR", args: args}, data) do
+ to_disconnected({:irc_error, Enum.join(args, " ")}, data)
+ end
+
+ def process_line(%Line{command: "PONG", args: [server | _]}, data = %{server: server}) do
+ {:keep_state_and_data, @conn_ping_timeout}
+ end
+
+ def process_line(%Line{command: "PING", args: args}, data = %{server: server}) do
+ case socksend(data, [?P, ?O, ?N, ?G, 0x20, ?:, args |> Enum.join(" ")]) do
+ :ok -> {:keep_state_and_data, @conn_ping_timeout}
+ error -> to_disconnected(error, data)
+ end
+ end
+
+ def process_line(%Line{command: "MODE", args: [nick, changes]}, data = %{nick: nick}) do
+ modes = Irc.Parser.Mode.changes(changes, data.modes)
+ send(data.process, {:irc_conn_modes, self(), changes, data.modes, modes})
+ {:keep_state, %State{data | modes: modes}}
+ end
+
+ def process_line(%Line{command: "NICK", args: [new_nick]}, data = %{nick: nick}) do
+ send(data.process, {:irc_conn_nick, self(), nick, new_nick})
+ {:keep_state, %State{data | nick: nick}}
+ end
+
+ def process_line(line, _) do
+ :ignore
+ end
+
+ # Disconnected state
+
+ @doc false
+ def disconnected(:enter, from, data) do
+ Logger.debug "#{inspect data} entering disconnected state"
+ :keep_state_and_data
+ end
+
+ def disconnect(:internal, error, data = %{args: %{reconnect: false}}) do
+ {:next_state, :error, data, {:next_event, :internal, error}}
+ end
+
+ def disconnected(:internal, :disconnected, _) do
+ :keep_state_and_data
+ end
+
+ def disconnected(:internal, error, data) do
+ {delay, backoff} = :backoff.fail(data.backoff)
+ send(data.process, {:irc_conn_down, self(), error, delay})
+ data = if data.socket do
+ data.sockmodule.close(data.socket)
+ %State{data | socket: nil, sockmodule: nil}
+ else
+ data
+ end
+ data = %State{data | welcomed: false, server: nil, nick: nil, welcome: [], motd: [], isupport: %{}, backoff: backoff}
+ Logger.error("#{inspect data} - #{inspect error} - (reconnecting in #{inspect delay})")
+ {:keep_state, data, {:state_timeout, delay, error}}
+ end
+
+ def disconnected({:call, ref}, :reconnect, data) do
+ {:next_state, :connect, data, [@internal, {:reply, ref, :ok}]}
+ end
+
+ def disconnected({:call, ref}, :info, data) do
+ {:keep_state_and_data, {:reply, ref, :disconnected}}
+ end
+
+ def disconnected({:call, ref}, {:disconnect, false, _}, data) do
+ {:keep_state_and_data, [{:state_timeout, :infinity, :disconnect}, {:reply, ref, {:error, :not_connected}}]}
+ end
+
+ def disconnected(:state_timeout, _error, data) do
+ {:next_state, :connect, data, @internal}
+ end
+
+ def disconnected(:info, {_, _, _}, data) do
+ :keep_state_and_data
+ end
+
+ def disconnected(:info, {closed, socket}, data) when closed in [:tcp_closed, :ssl_closed] do
+ :keep_state_and_data
+ end
+
+ def disconnected(type, content, data) do
+ handle_common(:disconnected, type, content, data)
+ end
+
+ # --- Fatal error state
+
+ @doc false
+ def error(:internal, error, data) do
+ send(data.process, {:irc_conn_error, self(), error})
+ {:stop, %State{data | errored: true}, error}
+ end
+
+ # --- Common defaults
+
+ @doc false
+ def handle_common(_, :enter, _, _) do
+ :keep_state_and_data
+ end
+
+ def handle_common(_, {:call, ref}, {:disconnect, stop, quit}, data = %State{socket: socket}) do
+ Logger.debug("#{inspect(data)} disconnecting on request (stop:#{inspect stop})")
+ if data.sockmodule && data.socket do
+ quit = if quit, do: quit, else: ""
+ socksend(data, ['QUIT :', quit])
+ data.sockmodule.close(socket)
+ end
+ if stop do
+ {:stop_and_reply, :normal, {:reply, ref, :ok}}
+ else
+ {:next_state, :disconnected, %State{data | socket: nil, sockmodule: nil}, [
+ {:reply, ref, :ok},
+ {:next_event, :internal, :disconnected},
+ ]}
+ end
+ end
+
+ def handle_common(_, {:call, ref}, {:disconnect, false, _}, data) do
+ {:next_state, :disconnected, data, [{:reply, ref, :ok}, {:next_event, :internal, :disconnected}]}
+ end
+
+ def handle_common(_, {:call, ref}, {:disconnect, true, _}, _) do
+ {:stop_and_reply, :normal, {:reply, ref, :ok}}
+ end
+
+ def handle_common(_, {:call, ref}, :info, data) do
+ {:keep_state_and_data, {:reply, ref, :connecting}}
+ end
+
+ def handle_common(_, {:call, ref}, :reconnect, _) do
+ {:keep_state_and_data, {:reply, ref, {:error, :not_disconnected}}}
+ end
+ def handle_common(_, {:call, ref}, {:send, _}, _) do
+ {:keep_state_and_data, {:reply, ref, {:error, :not_connected}}}
+ end
+
+ def handle_common(_, :cast, {:send, _}, _) do
+ :keep_state_and_data
+ end
+
+ def handle_common(_, :info, {_mod, _sock, line = [?P, ?I, ?N, ?G | _]}, data) do
+ line = Line.parse(line)
+ arg = Enum.join(line.args, " ")
+ case socksend(data, ['PONG :', arg]) do
+ :ok -> :keep_state_and_data
+ error -> to_disconnected(error, data)
+ end
+ end
+
+ def handle_common(_, :info, {_mod, _sock, line = [?E, ?R, ?R, ?O, ?R | _]}, data) do
+ line = Line.parse(line)
+ to_disconnected({:irc_error, Enum.join(line.args, " ")}, data)
+ end
+
+
+ def handle_common(_, :info, {_mod, _sock, _line}, data) do
+ {:keep_state_and_data, :postpone}
+ end
+
+ def handle_common(_, :info, {closed, socket}, data) when closed in [:tcp_closed, :ssl_closed] do
+ {:next_state, :disconnected, %State{data | socket: nil, sockmodule: nil}, @internal}
+ end
+
+ @doc false
+ def terminate(reason, state, data) do
+ unless data.errored do
+ send(data.process, {:irc_conn_error, self(), :down})
+ end
+ Logger.error("#{inspect(data)} terminating in #{inspect(state)}: #{inspect reason}")
+ end
+
+ @doc false
+ def code_change(_old_vsn, old_state, old_data, _extra) do
+ {:ok, old_state, old_data}
+ end
+
+ # Send helpers.
+ defp socksend(data = %State{sockmodule: module, socket: socket}, line) when is_list(line) do
+ Logger.debug("#{inspect data} >>> #{inspect line}")
+ module.send(socket, [line | [?\r, ?\n]])
+ end
+
+
+ # Helper to enter to the disconnected state.
+ defp to_disconnected(error, data) do
+ {:next_state, :disconnected, data, {:next_event, :internal, error}}
+ end
+
+ # -- Arguments processing/validation
+
+ # Prepare args (in start)
+ defp prepare_args(nick, host, args) do
+ args
+ |> Keyword.put_new(:nick, nick)
+ |> Keyword.put_new(:host, host)
+ |> Keyword.put_new(:process, self())
+ end
+
+ # Process args (in init)
+ defp process_args(args) do
+ host = Keyword.get(args, :host, nil)
+ port = Keyword.get(args, :port, nil) || 6667
+ tls = Keyword.get(args, :tls, nil) || false
+ inet = case Keyword.get(args, :inet, nil) do
+ 4 -> [:inet]
+ 6 -> [:inet6]
+ _ -> []
+ end
+ ip = Keyword.get(args, :ip, nil)
+ ifaddr = Keyword.get(args, :ifaddr, nil)
+ connect_timeout = Keyword.get(args, :connect_timeout, @connect_timeout)
+ nick = Keyword.get(args, :nick, nil)
+ user = Keyword.get(args, :user, nick) || nick
+ name = Keyword.get(args, :name, user) || user
+ pass = Keyword.get(args, :pass, nil)
+ capabs = Keyword.get(args, :capabs, []) || []
+ reconnect = Keyword.get(args, :reconnect, true)
+ tcp_opts = Keyword.get(args, :tcp_opts, []) || []
+ tls_opts = Keyword.get(args, :tls_opts, []) || []
+
+ with \
+ {:host, {:ok, host}} <- {:host, parse_host(host)},
+ {:port, {:ok, port}} <- {:port, parse_port(port)},
+ {:bind, {:ok, bind}} <- {:bind, parse_bind(ip, ifaddr)}
+ do
+ {:ok, %{
+ host: host,
+ port: port,
+ bind: bind,
+ tls: tls,
+ capabs: capabs,
+ inet: inet,
+ nick: nick,
+ user: user,
+ name: name,
+ pass: pass,
+ reconnect: reconnect,
+ tcp_opts: tcp_opts,
+ tls_opts: tls_opts,
+ connect_timeout: connect_timeout
+ }}
+ else
+ error -> error
+ end
+ end
+
+ defp parse_host(host) when is_binary(host) do
+ {:ok, host}
+ end
+ defp parse_host(host), do: {:error, {:invalid_format, :host, inspect(host)}}
+
+ defp parse_port(port) when is_integer(port) when port > 0 and port < 65336 do
+ {:ok, port}
+ end
+ defp parse_port(port), do: {:error, {:invalid_format, :port, inspect(port)}}
+
+ defp parse_bind(ip, _) when is_binary(ip) do
+ case :inet.parse_strict_address(String.to_charlist(ip)) do
+ {:ok, ip} -> {:ok, [{:ip, ip}]}
+ {:error, :einval} -> {:error, {:invalid_format, :ip, inspect(ip)}}
+ end
+ end
+ # TODO: Verify ifaddr is a valid interface
+ defp parse_bind(_, ifaddr) when is_binary(ifaddr) do
+ {:ok, [{:ifaddr, ifaddr}]}
+ end
+ defp parse_bind(nil, nil) do
+ {:ok, []}
+ end
+ defp parse_bind(ip, ifaddr) do
+ {:error, {:invalid_format, :bind, inspect([ip, ifaddr])}}
+ end
+
+ defimpl Inspect, for: State do
+ @moduledoc false
+ import Inspect.Algebra
+
+ def inspect(struct, _opts) do
+ status = cond do
+ struct.socket && struct.welcomed -> "registered"
+ struct.socket -> "connecting"
+ true -> "disconnected"
+ end
+ tls = if struct.args.tls, do: "+", else: ""
+ port = ":#{tls}#{struct.args.port}"
+ server = if struct.server do
+ "#{struct.args.host}#{port}[#{struct.server}]"
+ else
+ struct.args.host <> port
+ end
+ nick = struct.nick || struct.args.nick
+ info_string = Enum.join([inspect(self()), status, server, struct.args.user, nick], " ")
+ concat(["#Irc.Connection<", info_string, ">"])
+ end
+ end
+
+end