defmodule Irc.Connection do @behaviour :gen_statem require Logger alias Irc.ConnectionSocket alias Irc.Line alias Irc.Parser 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) @connection_capabs ["sasl", "cap-notify"] @internal {:next_event, :internal, nil} @default_handler Irc.Connection.MsgHandler @moduledoc """ Lightweight implementation of an IRC Connection handling only the basics: * the socket and reconnections, * registration process, * sasl authentication, * capabilities negotiation/lifecycle, * ping, * nick and user mode changes, * batches/echo. An implementation of `Irc.Connection.Handler` should be provided to interact with the connection. By default, the `Irc.Connection.MsgHandler` is used, which sends events to the owner process. ## Usage The connection is started using `start/4` or `start_link/4`, supplying a nickname, IRC host, and options `t:start_opt/0`: ```elixir {:ok, pid} = Irc.Connection.start("ExIrcTest", "irc.random.sh", [tls: true, port: 6697], []) ``` This will start a connection using the default handler: events will be sent to your PID, see `t:Irc.Connection.MsgHandler.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`. To start a connection with a custom handler, you need to set the `handler` and `handler_opts` start options: ```elixir {:ok, pid} = Irc.Connection.start("ExIrcTest", "irc.random.sh", [tls: true, port: 6697, handler: MyHandler, handler_opts: %{:something => true}], []) ``` If you are looking for a more featured client: you probably want `Irc.Client`. """ defstruct [:pid, :server, :host, :port, :tls, :sts, :nick, :modes, :capabs, :server_capabs, :version, :umodes, :cmodes, :cmodes_with_params, :isupport, :motd, :assigns, :__private__] @typedoc """ Connection Information state. Retrieve it with `info/1`. * `server`: reported server name, * `host`: connection host, * `port`: connection port, * `tls`: connection using tls, * `sts`: connection using sts, * `nick`: current connection nickname, * `capabs`: negotiated capabilities, * `server_capabs`: server offerred capabilities (with their parameters if any), * `modes`: connection modes, * `version`: reported server version, * `umodes`: user modes, * `cmodes` and `cmodes_with_params`: channel modes, * `isupport`: server ISUPPORT, see `Irc.Parser.Isupport`, * `motd`: MOTD. """ @type t :: %__MODULE__{ server: String.t, host: String.t, port: Integer.t, tls: boolean, sts: boolean, nick: String.t, capabs: [String.t], server_capabs: Irc.Parser.Capabs.t(), modes: String.t, version: String.t, umodes: String.t, cmodes: String.t, cmodes_with_params: String.t, isupport: Irc.Parser.Isupport.t(), motd: [String.t], assigns: Map.t() } # TODO: Better batch / Echo-message / message id # TODO: CAP lifecycle while connection is established # TODO: SASL # TODO: supports?/capab? by calling PID, more info to retrieve this way? @typedoc """ Start options. * user: IRC user, * name: IRC real name, * pass: IRC server password, * tls, * port (default 6667, 6697 if tls), * sts, see `t:start_opt_sts/0`, * reconnect (automatic/backoff reconnection), * [batch][batch] option, see `t:start_opt_batch/0`, * [sasl][sasl] options, see `t:start_opt_sasl/0`, * [webirc][webirc] options, see `t:start_opt_webirc/0`, * capabs: list to request, * connect options (either `t:gen_tcp.connect_option/0` or `t:ssl.start_option/0`, depending on the value of the `tls`), * owner process, * handler_module and handler_args. """ @type start_opt :: {:user, nil | String.t} | {:name, nil | String.t} | {:pass, nil | String.t} | {:port, 6667 | 6697 | Integer.t} | {:tls, false | true} | {:sts, start_opt_sts()} | {:reconnect, true | false} | {:sasl, start_opt_sasl()} | {:batch, start_opt_batch()} | {:echo, start_opt_echo()} | {:webirc, nil | start_opt_webirc()} | {:capabs, [String.t()]} | {:connect_opts, [:gen_tcp.connect_option | :ssl.connect_option]} | {:process, pid()} | {:handler, Irc.Connection.MsgHandler | module()} | {:handler_args, %{}} @typedoc """ IRCv3 [Strict Transport Security]() Enabled by default. If a STS policy is enabled or known and the connection not secured, the client will automatically reconnect with tls. """ @type start_opt_sts :: true | false @typedoc """ [SASL][sasl] Authentication options [sasl]: https://ircv3.net/specs/extensions/sasl-3.1.html """ @type start_opt_sasl :: %{mechanism :: String.t => [mechanism_param :: String.t]} @typedoc """ IRCv3 [Batch][batch] support. Enabled by default. If negotiated, batched lines are sent with the `t:msg_batch/0` message. If the capability wasn't negotiated or disabled, individual `t:msg_line/0` messages will be sent. [batch]: https://ircv3.net/specs/extensions/batch-3.2 """ @type start_opt_batch :: true | false @typedoc """ IRCv3 [echo-message](https://ircv3.net/specs/extensions/echo-message-3.2) support. Enabled by default. If negotiated, PRIVMSG and NOTICE sent by the connection will be echoed by the server. """ @type start_opt_echo :: true | false @typedoc """ Send a [WEBIRC][webirc] command upon connection. ``` {password, gateway, user_hostname, user_port, flags} {"hunter5", "web.irc.mynetwork", "some-user-hostname", "127.0.0.1", %{"secure" => true}} ``` [webirc]: https://ircv3.net/specs/extensions/webirc """ @type start_opt_webirc :: {password ::String.t, gateway :: String.t, hostname :: String.t, ip :: String.t, options :: Map.t} @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 @spec capab?(t, String.t) :: boolean @doc "Returns true if the capab `feature` has been negotiated for this connection." def capab?(%__MODULE__{capabs: capabs}, feature) do Map.has_key?(capabs, feature) end @spec supports?(t, String.t) :: boolean @doc "Returns true if `feature` is reported as supported by the server (ISUPPORT)." def supports?(%__MODULE__{isupport: isupport}, feature) do Map.has_key?(isupport, String.upcase(feature)) 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 # # --- STATE MACHINE # # * connect -> [reg_webirc -> cap_ls -> reg_nick -> reg_pass -> cap_req -> cap_wait_ack -> welcome -> motd] # -> [disconnected, registered] # * disconnected -> [connect] defmodule State do @moduledoc false defstruct [ :args, # Start arguments :backoff, # Backoff state :socket, # ConnectionSocket :process, # Owner PID :monitor, # Owner monitor :nick, # Current nickname :server, # Server name :version, # Server version :umodes, # Server umodes :cmodes, # Server cmodes :cmodes_with_params, # Server parametized cmodes :welcomed, # Registration :tls, # Connected with TLS :cap_302, # If server supports 302 :sts, # If an STS policy has been used :port, # Actual connection port (may differ than args.port due to STS) {:batches, %{}}, # Active batches {:errored, false}, # TODO {:isupport, %{}}, # Server ISUPPORT {:modes, []}, {:server_capabs, %{}}, # Server advertised capabs {:client_capabs, []}, # Client enabled capabs {:capabs, []}, # Negotiated capabs {:motd, []}, {:welcome, []}, # Registration log, {:assigns, %{}}, {:__private__, %{}} ] end @doc false def callback_mode, do: [:state_functions, :state_enter] @doc false def init([start_args]) do process = Map.get(start_args, :process, nil) monitor = Process.monitor(process) with \ backoff <- init_backoff(start_args), {:ok, args} <- process_args(start_args), {tls, sts, port} <- init_sts(args), {:ok, handler_args} <- handler_init(args), {:ok, handler_capabs} <- handler_capabs(args, handler_args) do client_capabs = [ Irc.Parser.capabs(), @connection_capabs, if(args.batch, do: ["batch"]), if(args.sasl, do: ["sasl"]), if(args.echo, do: ["echo-message"]), args.capabs, handler_capabs ] |> Enum.filter(fn(i) -> i end) |> List.flatten() |> Enum.uniq() data = %State{ args: args, sts: sts, port: port, tls: tls, client_capabs: client_capabs, 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) do Logger.debug("#{inspect(data)} connecting...") options = [{:active, true}, {:packet, :line}] ++ data.args.connect_opts case ConnectionSocket.connect(data.tls, data.args.host, data.port, options, __MODULE__) do {:ok, socket} -> {_, backoff} = :backoff.succeed(data.backoff) {:next_state, :reg_webirc, %State{data | socket: socket, backoff: backoff}, @internal} error -> # TODO ERRORS to_disconnected(error, data) end end def connect(type, content, data) do handle_common(:connect, type, content, data) end # Registration: WEBIRC # https://ircv3.net/specs/extensions/webirc @doc false def reg_webirc(:internal, _, data = %{args: %{webirc: {password, gateway, hostname, ip, options}}}) do opt_string = Enum.map(options, fn({key, value}) -> if value == true do key else key <> "=" <> value end end) |> Enum.join(" ") case socksend(data, ['WEBIRC ', password, ' ', gateway, ' ', hostname, ' ', ip, ' :', options]) do :ok -> {:next_state, :cap_ls, data, @internal} error -> to_disconnected(error, data) end end def reg_webirc(:internal, _, data) do {:next_state, :cap_ls, data, @internal} end def reg_webirc(type, content, data) do handle_common(:reg_webirc, 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("#{inspect data} - cap_ls: Disconnected in socksend") to_disconnected(error, data) end end # multiline capab def cap_ls(:info, {_, _, %Line{command: "CAP", args: [_, "LS", "*", rest]} = line}, data) do Logger.debug "#{inspect(data)} <<< #{inspect(line)}" capabs = Map.merge(data.server_capabs, Parser.Capabs.parse(rest)) {:keep_state, %State{data | server_capabs: capabs, cap_302: true}, {:state_timeout, @reply_timeout, nil}} end # end of multiline or no multiline indicator def cap_ls(:info, {_, _, %Line{command: "CAP", args: [_, "LS", rest]} = line}, data) do Logger.debug "#{inspect(data)} <<< #{inspect(line)}" capabs = Map.merge(data.server_capabs, Parser.Capabs.parse(rest)) if data.cap_302 do {:next_state, :cap_sts, %State{data | server_capabs: capabs}, @internal} else {:keep_state, %State{data | server_capabs: capabs}, {:state_timeout, @reply_timeout, nil}} end end # state timeout - consider LS complete def cap_ls(:state_timeout, _, data) do Logger.debug("#{inspect data} - CAP LS State timeout") {:next_state, :cap_sts, data, @internal} end def cap_ls(type, content, data) do handle_common(:cap_ls, type, content, data) end # REGISTRATION: Check for STS # STS is not already enabled on this connection, and a policy is advertised with a port: follow the policy and # reconnect. @doc false def cap_sts(:internal, _, data = %{args: %{sts: true}, sts: false, port: current_port, server_capabs: %{"sts" => policy = %{"port" => sts_port}}}) when current_port != sts_port do socksend(data, Line.new("QUIT", ["Reconnecting following STS policy."])) ConnectionSocket.close(data.socket) {:next_state, :connect, %State{data | sts: true, port: sts_port, tls: true, socket: nil}, @internal} end # STS is enabled and we are over TLS, witness the policy. def cap_sts(:internal, _, data = %{args: %{sts: true}, sts: true, tls: true, server_capabs: %{"sts" => policy}}) do Irc.STS.witness(data.args.host, policy) {:next_state, :reg_pass, data, @internal} end # invalid conditions for sts, exit state def cap_sts(:internal, _, data) do {:next_state, :reg_pass, data, @internal} end def cap_sts(type, content, data) do handle_common(:cap_sts, 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, Line.new("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, {_, _, %Line{command: err_PASSWDMISMATCH()}} = line, data) do Logger.debug "#{inspect(data)} <<< #{inspect(line)}" {:next_state, :error, {:invalid_password, data.args.pass}} 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 nick = nick || data.nick || data.args.nick case socksend(data, Line.new("NICK", 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, {_, _, %Line{command: cmd} = line}, data) when cmd in [err_NICKNAMEINUSE(), err_NICKCOLLISION() ]do Logger.debug "#{inspect(data)} <<< #{inspect(line)}" new_nick = data.args.nick <> to_string(:random.uniform(9999)) {:keep_state, data, {:next_event, :internal, new_nick}} end def reg_nick(:info, {_, _, line = %Line{command: cmd}}, data) when cmd in [err_ERRONEUSNICKNAME(), err_NONICKNAMEGIVEN()] do Logger.debug "#{inspect(data)} <<< #{inspect(line)}" {:next_state, :error, data, {:next_event, :internal, {:invalid_nickname, data.args.nick, line}}} 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 case socksend(data, Line.new("USER", [data.args.user, "0", "*", data.args.name])) do :ok -> {:next_state, :cap_req, data, @internal} error -> to_disconnected(error, data) end end def reg_user(type, content, data) do handle_common(:reg_user, type, content, data) end # --- Registration: CAP REQ @doc false # No advertised server capabs, skip to WELCOME phase. def cap_req(:internal, _, data = %{server_capabs: server_capabs}) when server_capabs == %{} do {:next_state, :welcome, data} end def cap_req(:internal, _, data = %{client_capabs: client_capabs, server_capabs: server_capabs}) do req = Enum.filter(client_capabs, fn(capab) -> Map.has_key?(server_capabs, capab) end) |> Enum.uniq() |> Enum.join(" ") case socksend(data, Line.new("CAP", ["REQ", 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 # --- Registration: CAP ACK/NACK @doc false def cap_wait_ack(:enter, _, _) do {:keep_state_and_data, {:state_timeout, @reply_timeout, nil}} end # FIXME: Support multiline ACK/NACK ??? # TODO: VERIFY SPEC CONFORMITY def cap_wait_ack(:info, {_, _, %Line{command: "CAP", args: [_, "ACK", capabs]} = line}, data) do Logger.debug "#{inspect(data)} <<< #{inspect(line)}" capabs = String.split(capabs, " ") {:keep_state, %State{data | capabs: capabs}, {:next_event, :internal, :end}} end def cap_wait_ack(:info, {_, _, %Line{command: "CAP", args: [_, "NACK", capabs]} = line}, data) do Logger.debug "#{inspect(data)} <<< #{inspect(line)}" capabs = String.split(capabs, " ") {:keep_state, %State{data | capabs: data.capabs -- capabs}, {:next_event, :internal, :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 def cap_wait_ack(type, message, data) do handle_common(:cap_wait_ack, type, message, data) end # --- REGISTRATION: Welcome @doc false def welcome(:enter, _, data) do {:keep_state_and_data, {:state_timeout, @registration_timeout, nil}} end def welcome(:info, {_, _, l = %Line{command: rpl_WELCOME(), source: source, args: [nick, _]}}, data) do Logger.debug "#{inspect(data)} <<< #{inspect(l)}" {:keep_state, %State{data | nick: nick, welcome: [l | data.welcome], server: source}, {:state_timeout, @reply_timeout, nil}} end @welcome_numeric [rpl_WELCOME, rpl_YOURHOST, rpl_CREATED, rpl_MYINFO, rpl_ISUPPORT] def welcome(:info, {_, _, l = %Line{command: rpl_MYINFO(), source: source, args: [_, _, version, umodes, cmodes | rest]}}, data) do Logger.debug "#{inspect(data)} <<< #{inspect(l)}" cmodes_with_params = case rest do [] -> nil [c] -> c end data = %State{data | welcome: [l | data.welcome], version: version, umodes: umodes, cmodes: cmodes, cmodes_with_params: cmodes_with_params} {:keep_state, data, {:state_timeout, @reply_timeout, nil}} end def welcome(:info, {_, _, %Line{command: rpl_ISUPPORT(), args: [_ | isupport]} = line}, data) do Logger.debug "#{inspect(data)} <<< #{inspect(line)}" supports = Irc.Parser.Isupport.parse(isupport, data.isupport) {:keep_state, %State{data | isupport: supports}, {:state_timeout, @reply_timeout, nil}} end # No MOTD/Start of MOTD ends the welcome phase def welcome(:info, {_, _, %Line{command: cmd}}, data) when cmd in [err_NOMOTD(), rpl_MOTDSTART()] do {:next_state, :motd, data, [:postpone]} 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{command: err_NOMOTD()} = line}, data) do Logger.debug "#{inspect(data)} <<< #{inspect(line)}" {:next_state, :registered, %State{data | motd: []}} end def motd(:info, {_, _, %Line{command: rpl_MOTDSTART(), args: [_, motd_line]} = line}, data) do Logger.debug "#{inspect(data)} <<< #{inspect(line)}" {:keep_state, %State{data | motd: [motd_line]}, {:state_timeout, @reply_timeout, nil}} end def motd(:info, {_, _, %Line{command: rpl_MOTD(), args: [_, motd_line]} = line}, data) do {:keep_state, %State{data | motd: [motd_line | data.motd]}, {:state_timeout, @reply_timeout, nil}} end def motd(:info, {_, _, %Line{command: rpl_ENDOFMOTD(), args: [_, motd_line]} = line}, data) do Logger.debug "#{inspect(data)} <<< #{inspect(line)}" {:next_state, :registered, %State{data | motd: Enum.reverse([motd_line | data.motd])}} 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, :port, :tls, :sts, :modes, :server, :version, :umodes, :cmodes, :cmodes_with_params, :capabs, :isupport, :motd, :assigns, :__private__]) |> Map.put(:host, data.args.host) |> Map.put(:pid, self()) |> 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 } {:ok, data} = handler_run_event(data, :connected, [:TODO_PASS_ARGS]) #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 Logger.debug "#{inspect(data)} <<< #{inspect(line)}" case process_line(line, data) do :ignore -> {:ok, data} = handler_run_event(data, :line, [line]) {:keep_state, 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.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]} = line, data = %{nick: nick}) do if Line.to?(line, to_info(:registered, data)) 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", source: 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 prev = data.modes modes = Irc.Parser.Mode.changes(changes, data.modes) {:ok, data} = %State{data | modes: modes} |> handler_run_event(:modes, [changes, prev]) {:keep_state, data} end def process_line(%Line{command: "NICK", args: [new_nick]}, data = %{nick: nick}) do {:ok, data} = %State{data | nick: new_nick} |> handler_run_event(:nick, [nick, new_nick]) {:keep_state, %State{data | nick: nick}} end # -- BATCH # TODO: Improve batch - support nested in nested, ... def process_line(line = %Line{command: "BATCH", args: ["+"<>batchid, type | args], tags: tags}, data) do {batches, outer_id} = if outer_batchid = Map.get(tags, "batch") do if outer = Map.get(data.batches, outer_batchid) do outer = Map.put(outer, :batches, [batchid | outer.batches]) {Map.put(data.batches, outer_batchid, outer), outer_batchid} end else {data.batches, nil} end batch = %{type: type, args: args, lines: [], tags: tags, outer: outer_id, batches: [], source: line.source} {:keep_state, %State{data | batches: Map.put(batches, batchid, batch)}} end def process_line(line = %Line{command: "BATCH", args: ["-"<>batchid | args]}, data) do %{type: type, args: args, lines: lines, tags: tags, outer: outer_id, batches: batches} = Map.get(data.batches, batchid) if outer_id do :keep_state_and_data else for batchid <- [batchid | Enum.reverse(batches)] do %{type: type, args: args, lines: lines, tags: tags} = batch = Map.get(data.batches, batchid) batch = batch |> Map.put(:lines, Enum.reverse(lines)) |> Map.put(:__private__, line.__private__) batch = struct(Irc.Batch, batch) if Keyword.get(data.args, :batch, true) do {:ok, data} = handler_run_event(data, :batch, batch) else for l <- Enum.reverse(lines), do: send(data.process, {:irc_conn_line, self(), l}) end end {:keep_state, %State{data | batches: Map.drop(data.batches, batchid)}} end end def process_line(line = %Line{tags: %{"batch" => batch}}, data) do if batch_data = Map.get(data.batches, batch) do tags = Map.drop(line.tags, "batch") batch_data = Map.put(batch_data, :lines, [%{line | tags: tags} | batch_data.lines]) {:keep_state, %State{data | batches: Map.put(data.batches, batch, batch_data)}} else :ignore end 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 ConnectionSocket.close(data.socket) %State{data | socket: nil} else data end data = %State{data | welcomed: false, server: 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, {ConnectionSocket, _socket, :closed}, data) 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.socket do quit = if quit, do: quit, else: "" socksend(data, ['QUIT :', quit]) ConnectionSocket.close(socket) end if stop do {:stop_and_reply, :normal, {:reply, ref, :ok}} else {:next_state, :disconnected, %State{data | socket: 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 # TODO: Decide if we should postpone or blanket refuse. {:keep_state_and_data, {:reply, ref, {:error, :not_connected}}} #{:keep_state_and_data, :postpone} end def handle_common(_, :cast, {:send, _}, _) do :keep_state_and_data end def handle_common(_, :info, {_, _, %Line{command: "PING", args: args}}, data) do arg = Enum.join(args, " ") case socksend(data, ['PONG :', arg]) do :ok -> :keep_state_and_data error -> to_disconnected(error, data) end end def handle_common(_, :info, {_, _, %Line{command: "ERROR", args: args}}, data) do to_disconnected({:irc_error, Enum.join(args, " ")}, data) end def handle_common(_, :info, {_mod, _sock, _line}, data) do {:keep_state_and_data, :postpone} end # TODO: SOCKET ERROR def handle_common(_, :info, {ConnectionSocket, _socket, :closed}, data) do Logger.error("#{inspect data} - socket closed") {:next_state, :disconnected, %State{data | socket: nil}, @internal} end # SOCKET DIED def handle_common(_, :info, {:DOWN, ref, :process, socket_pid, reason}, data = %{socket: {ConnectionSocket,socket_pid,ref}}) do Logger.error("#{inspect data} - Socket pid died #{inspect reason}") {:next_state, :disconnected, %State{data | socket: nil}, @internal} end # OWNER DIED def handle_common(_, :info, {:DOWN, _, :process, pid, reason}, data = %{process: pid}) do Logger.error("#{inspect data} - Owner pid died #{inspect reason}") {:stop, :normal, data} end @doc false def terminate(reason, state, data) do if data.socket do ConnectionSocket.close(data.socket) end 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, line = %Line{}) do socksend(data, [Parser.Line.encode(line, to_info(data))]) end defp socksend(data = %State{socket: socket}, line) when is_list(line) do Logger.debug("#{inspect data} >>> #{inspect line}") ConnectionSocket.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 # -- Init helpers defp init_backoff(start_args) do min = Map.get(start_args, :backoff_min, @backoff_min) max = Map.get(start_args, :backoff_max, @backoff_max) :backoff.init(min, max) end @spec init_sts(Map.new) :: {use_tls :: boolean, active_sts_policy :: boolean, port :: non_neg_integer} defp init_sts(args = %{sts: true}) do case Irc.STS.lookup(args.host) do {:ok, port} -> {true, true, port} _ -> init_sts(%{args | sts: false}) end end defp init_sts(%{tls: tls, port: port}) do {tls, false, port} 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()) |> Enum.into(Map.new) end # Process args (in init) defp process_args(args) do host = Map.get(args, :host) tls = Map.get(args, :tls) || false sts = Map.get(args, :sts) || true default_port = if tls, do: 6697, else: 6667 port = Map.get(args, :port) || default_port connect_timeout = Map.get(args, :connect_timeout, @connect_timeout) nick = Map.get(args, :nick) user = Map.get(args, :user) || nick name = Map.get(args, :name) || user pass = Map.get(args, :pass) capabs = Map.get(args, :capabs) || [] reconnect = Map.get(args, :reconnect, true) batch = Map.get(args, :batch, true) sasl = Map.get(args, :sasl, false) echo = Map.get(args, :echo, true) connect_opts = Map.get(args, :connect_opts) || [] handler = Map.get(args, :handler, @default_handler) handler_opts = Map.get(args, :handler_opts, []) with \ {:nick, :ok} <- {:nick, verify_nickname(nick)}, {:host, {:ok, host}} <- {:host, parse_host(host)}, {:port, {:ok, port}} <- {:port, parse_port(port)} do {:ok, %{ host: host, tls: tls, sts: sts, port: port, capabs: capabs, nick: nick, user: user, name: name, pass: pass, reconnect: reconnect, batch: batch, sasl: sasl, echo: echo, connect_opts: connect_opts, connect_timeout: connect_timeout, handler: handler, handler_opts: handler_opts }} 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 verify_nickname(nick) when is_binary(nick) and byte_size(nick) > 1 do :ok end defp verify_nickname(nick), do: {:error, {:invalid_nickname, nick}} defp handler_init(args) do args.handler.init(args) end def handler_capabs(args, h_args) do args.handler.capabs(h_args) end defp handler_run_event(data = %{handler: handler}, callback, args) do args = [to_info(:connected, data) | args] case :erlang.apply(handler, callback, args) do {:ok, info} -> {:ok, %State{data | assigns: info.assigns, __private__: info.__private__}} error -> error end end defimpl Irc.Context, for: __MODULE__ do def capab?(conn, capab) do Enum.member?(conn.capabs, capab) end def capabs(conn) do conn.capabs end def pid(conn) do conn.pid end def module(conn) do conn.__struct__ end end defimpl Irc.Addressable, for: __MODULE__ do def nick(conn), do: conn.nick def user(_), do: nil def host(_), do: nil def owner(conn), do: conn.pid def owner_module(conn), do: conn.__struct__ end defimpl Irc.Context, for: State do def capab?(state, capab) do Enum.member?(state.capabs, capab) end def capabs(state) do state.capabs end def pid(_) do self() end def module(state) do Irc.Connection end end defimpl Irc.Addressable, for: State do def nick(state), do: state.nick def user(_), do: nil def host(_), do: nil def owner(_), do: self() def owner_module(), do: Irc.Connection 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