diff options
author | href <href@random.sh> | 2021-01-11 15:53:02 +0100 |
---|---|---|
committer | href <href@random.sh> | 2021-01-11 15:53:02 +0100 |
commit | 0f3f0e035b43eabd3f739c41964446962cf54208 (patch) | |
tree | 9279c54e100c92375c9d980e2031e0a153245025 /lib/irc/connection.ex | |
parent | Some fixes (diff) |
Cont. wipmaster
Diffstat (limited to 'lib/irc/connection.ex')
-rw-r--r-- | lib/irc/connection.ex | 313 |
1 files changed, 215 insertions, 98 deletions
diff --git a/lib/irc/connection.ex b/lib/irc/connection.ex index 23eee3f..6df8773 100644 --- a/lib/irc/connection.ex +++ b/lib/irc/connection.ex @@ -2,7 +2,7 @@ defmodule Irc.Connection do @behaviour :gen_statem require Logger alias Irc.ConnectionSocket - alias Irc.Parser.Line + alias Irc.Line alias Irc.Parser require Irc.Parser.Numeric import Irc.Parser.Numeric @@ -15,6 +15,7 @@ defmodule Irc.Connection do @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: @@ -27,26 +28,34 @@ defmodule Irc.Connection do * nick and user mode changes, * batches/echo. - Events and lines are sent to calling PID. + 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 client is started using `start/4` or `start_link/4`, supplying a nickname, IRC host, and options `t:start_opt/0`: + 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], []) + {: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. + 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`. - If you are looking for a more featured client, you probably want `Irc.Client`! + 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 [:server, :host, :port, :tls, :sts, :nick, :modes, :capabs, :server_capabs, :version, :umodes, :cmodes, :cmodes_with_params, :isupport, :motd] + 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`. @@ -82,35 +91,9 @@ defmodule Irc.Connection do cmodes_with_params: String.t, isupport: Irc.Parser.Isupport.t(), motd: [String.t], + assigns: Map.t() } - @typedoc "Messages sent to the owning process." - @type message :: msg_up() | msg_down() | msg_error() - | msg_line() | msg_batch() | 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: Line batch (only if enabled, see `batch` in `t:start_opt/0`)." - @type msg_batch :: {:irc_conn_batch, pid(), type :: String.t(), type_params :: [String.t()], lines :: [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} - # TODO: Better batch / Echo-message / message id # TODO: CAP lifecycle while connection is established # TODO: SASL @@ -131,7 +114,8 @@ defmodule Irc.Connection do * [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. + * owner process, + * handler_module and handler_args. """ @type start_opt :: {:user, nil | String.t} @@ -148,6 +132,8 @@ defmodule Irc.Connection do | {: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]() @@ -161,7 +147,7 @@ defmodule Irc.Connection do [sasl]: https://ircv3.net/specs/extensions/sasl-3.1.html """ - @type start_opt_sasl :: %{mechanism :: String.t => [mechanism_param() :: String.t]} + @type start_opt_sasl :: %{mechanism :: String.t => [mechanism_param :: String.t]} @typedoc """ IRCv3 [Batch][batch] support. @@ -301,17 +287,20 @@ defmodule Irc.Connection do defmodule State do @moduledoc false - defstruct [:args, - :backoff, :socket, - :process, :monitor, - :nick, - :server, - :version, - :umodes, - :cmodes, - :cmodes_with_params, - :welcomed, - :tls, + 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) @@ -323,7 +312,9 @@ defmodule Irc.Connection do {:client_capabs, []}, # Client enabled capabs {:capabs, []}, # Negotiated capabs {:motd, []}, - {:welcome, []}, # Registration log + {:welcome, []}, # Registration log, + {:assigns, %{}}, + {:__private__, %{}} ] end @@ -331,32 +322,34 @@ defmodule Irc.Connection do def callback_mode, do: [:state_functions, :state_enter] @doc false - def init([args]) do - backoff_min = Map.get(args, :backoff_min, @backoff_min) - backoff_max = Map.get(args, :backoff_max, @backoff_max) - process = Map.get(args, :process, nil) + def init([start_args]) do + process = Map.get(start_args, :process, nil) monitor = Process.monitor(process) with \ - {:ok, args} <- process_args(args), - backoff <- :backoff.init(backoff_min, backoff_max) + 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 - {tls, sts, port} = case {args.sts, Irc.STS.lookup(args.host)} do - {true, {:ok, port}} -> {true, true, port} - _ -> {args.tls, false, args.port} - end - 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, - ] - |> Enum.filter(fn(i) -> i end) - |> List.flatten() - |> Enum.uniq() + 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, + sts: sts, + port: port, + tls: tls, client_capabs: client_capabs, process: process, monitor: monitor, @@ -375,7 +368,7 @@ defmodule Irc.Connection do 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) do + 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} @@ -516,13 +509,14 @@ defmodule Irc.Connection do @doc false def reg_nick(:internal, nick, data) do - case socksend(data, Line.new("NICK", nick || data.args.nick)) 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 + 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}} @@ -669,9 +663,9 @@ defmodule Irc.Connection do {:next_state, :registered, %State{data | motd: []}} end - def motd(:info, {_, _, %Line{command: rpl_MOTDSTART(), args: [_, line]} = line}, data) do + def motd(:info, {_, _, %Line{command: rpl_MOTDSTART(), args: [_, motd_line]} = line}, data) do Logger.debug "#{inspect(data)} <<< #{inspect(line)}" - {:keep_state, %State{data | motd: [line]}, {:state_timeout, @reply_timeout, nil}} + {: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 @@ -696,8 +690,9 @@ defmodule Irc.Connection do 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]) + |> 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 @@ -710,8 +705,9 @@ defmodule Irc.Connection do motd: Enum.reverse(data.motd), welcomed: true } - msg = {:irc_conn_up, self(), to_info(data, :registered)} - send(data.process, msg) + {: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 @@ -720,9 +716,8 @@ defmodule Irc.Connection do 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 + {:ok, data} = handler_run_event(data, :line, [line]) + {:keep_state, data} return -> return end @@ -766,8 +761,9 @@ defmodule Irc.Connection 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 + 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 @@ -790,35 +786,60 @@ defmodule Irc.Connection do 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) - send(data.process, {:irc_conn_modes, self(), changes, data.modes, modes}) - {:keep_state, %State{data | modes: 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 - send(data.process, {:irc_conn_nick, self(), nick, new_nick}) + {:ok, data} = %State{data | nick: new_nick} + |> handler_run_event(:nick, [nick, new_nick]) {:keep_state, %State{data | nick: nick}} end # -- BATCH - def process_line(%Line{command: "BATCH", args: ["+"<>batchid, type | args]}, data) do - batch = %{type: type, args: args, lines: []} - {:keep_state, %State{data | batches: Map.put(data.batches, batchid, 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{command: "BATCH", args: ["-"<>batchid | args]}, data) do - %{type: type, args: args, lines: lines} = Map.get(data.batches, batchid) - if Keyword.get(data.args, :batch, true) do - send(data.process, {:irc_conn_batch, self(), type, args, Enum.reverse(lines)}) + 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 l <- Enum.reverse(lines), do: send(data.process, {:irc_conn_line, self(), l}) + 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 - {:keep_state, %State{data | batches: Map.drop(data.batches, batchid)}} end def process_line(line = %Line{tags: %{"batch" => batch}}, data) do if batch_data = Map.get(data.batches, batch) do - batch_data = Map.put(batch_data, :lines, [line | batch_data.lines]) + 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 @@ -854,7 +875,7 @@ defmodule Irc.Connection do else data end - data = %State{data | welcomed: false, server: nil, nick: nil, welcome: [], motd: [], isupport: %{}, backoff: backoff} + 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 @@ -935,7 +956,9 @@ defmodule Irc.Connection 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 @@ -995,7 +1018,7 @@ defmodule Irc.Connection do # Send helpers. defp socksend(data, line = %Line{}) do - socksend(data, [Line.encode(line, to_info(data))]) + 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}") @@ -1008,6 +1031,24 @@ defmodule Irc.Connection 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) @@ -1037,6 +1078,8 @@ defmodule Irc.Connection do 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)}, @@ -1058,7 +1101,9 @@ defmodule Irc.Connection do sasl: sasl, echo: echo, connect_opts: connect_opts, - connect_timeout: connect_timeout + connect_timeout: connect_timeout, + handler: handler, + handler_opts: handler_opts }} else error -> error @@ -1081,6 +1126,78 @@ defmodule Irc.Connection do 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 |