diff options
Diffstat (limited to 'lib/irc/connection.ex')
-rw-r--r-- | lib/irc/connection.ex | 928 |
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 |