From 40a234c6e59d2ea9ef309850b8f38ad30e55017e Mon Sep 17 00:00:00 2001 From: href Date: Tue, 14 Jan 2020 16:03:05 +0100 Subject: =?UTF-8?q?=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 16 +- lib/irc/application.ex | 8 +- lib/irc/base_client.ex | 170 ------------------- lib/irc/base_client/capabs.ex | 0 lib/irc/client.ex | 265 ++++++++++++++++++++++++++--- lib/irc/client/command.ex | 50 ++++++ lib/irc/client/command/account.ex | 18 ++ lib/irc/client/command/away.ex | 25 +++ lib/irc/client/command/chghost.ex | 15 ++ lib/irc/client/command/invite.ex | 21 +++ lib/irc/client/command/join.ex | 40 +++++ lib/irc/client/command/names.ex | 22 +++ lib/irc/client/command/who.ex | 62 +++++++ lib/irc/client/user_cache.ex | 31 ++++ lib/irc/connection.ex | 347 +++++++++++++++++++++++++++----------- lib/irc/mask.ex | 52 ++++++ lib/irc/parser.ex | 6 + lib/irc/parser/isupport.ex | 32 +++- lib/irc/parser/line.ex | 161 ++++++++++++++---- lib/irc/parser/numeric.ex | 5 +- lib/irc/parser/prefix.ex | 47 ++++++ lib/irc/sts.ex | 169 +++++++++++++++++++ lib/irc/user.ex | 46 +++++ priv/numerics.txt | 2 + test/mask_test.exs | 22 +++ test/parser/isupport_test.exs | 36 ++++ test/parser/line_parser.exs | 45 ----- test/parser/line_test.exs | 87 ++++++++++ test/parser/prefix_test.exs | 39 +++++ 29 files changed, 1449 insertions(+), 390 deletions(-) delete mode 100644 lib/irc/base_client.ex delete mode 100644 lib/irc/base_client/capabs.ex create mode 100644 lib/irc/client/command.ex create mode 100644 lib/irc/client/command/account.ex create mode 100644 lib/irc/client/command/away.ex create mode 100644 lib/irc/client/command/chghost.ex create mode 100644 lib/irc/client/command/invite.ex create mode 100644 lib/irc/client/command/join.ex create mode 100644 lib/irc/client/command/names.ex create mode 100644 lib/irc/client/command/who.ex create mode 100644 lib/irc/client/user_cache.ex create mode 100644 lib/irc/mask.ex create mode 100644 lib/irc/parser/prefix.ex create mode 100644 lib/irc/sts.ex create mode 100644 lib/irc/user.ex create mode 100644 test/mask_test.exs create mode 100644 test/parser/isupport_test.exs delete mode 100644 test/parser/line_parser.exs create mode 100644 test/parser/line_test.exs create mode 100644 test/parser/prefix_test.exs diff --git a/README.md b/README.md index 8e99077..4b81d33 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,26 @@ # Irc -An IRC toolkit for Elixir. Compatible IRCv3! +An modern IRC toolkit for Elixir. Compatible IRCv3! * `Irc.Parser` a versatile IRC parser; * `Irc.Connection` a basic IRC connection (handling registration and socket); -* `Irc.BaseClient` an extensible IRC client; -* `Irc.Client` a classical IRC client (implemented on BasicClient). +* `Irc.Client` a fully featured and extensible IRC client. Examples of usage: `Irc.Shout` (a simple connect-join-message-quit on Connection), `Irc.Client` on BaseClient. Future versions may include: bots, server parser, server-to-server connections, …. +### Supported features + +The eventual goal is to support most of the modern IRC specifications. + +The parser supports the following capabs: `message-tags`, `multi-prefix`, `account-tag`, `server-time`. + +The connection supports the following: `sasl`, `sts`, `cap-notify`, `batch`. + +The client has modules for the following: `account-notify`, `away-notify`, `userhost-in-names`, `chghost`, +`invite-notify`, `extended-join`; and `labeled-response` - `echo-message`. + ## Installation If [available in Hex](https://hex.pm/docs/publish), the package can be installed diff --git a/lib/irc/application.ex b/lib/irc/application.ex index 82fc053..70b3d0f 100644 --- a/lib/irc/application.ex +++ b/lib/irc/application.ex @@ -1,19 +1,13 @@ defmodule Irc.Application do - # See https://hexdocs.pm/elixir/Application.html - # for more information on OTP Applications @moduledoc false use Application def start(_type, _args) do - # List all child processes to be supervised children = [ - # Starts a worker by calling: Irc.Worker.start_link(arg) - # {Irc.Worker, arg} + {Irc.STS, []} ] - # See https://hexdocs.pm/elixir/Supervisor.html - # for other strategies and supported options opts = [strategy: :one_for_one, name: Irc.Supervisor] Supervisor.start_link(children, opts) end diff --git a/lib/irc/base_client.ex b/lib/irc/base_client.ex deleted file mode 100644 index 8da45be..0000000 --- a/lib/irc/base_client.ex +++ /dev/null @@ -1,170 +0,0 @@ -defmodule Irc.BaseClient do - @behaviour :gen_statem - require Logger - alias Irc.Parser.Line - alias Irc.Connection - require Irc.Parser.Numeric - import Irc.Parser.Numeric - - @moduledoc """ - Extensible fully-featured IRC client. - - ## Behaviour - - The BaseClient requires a callback module. You can check out `Irc.Client`. - - ## Capabs - - IRCv3 Capabs are handled by separates modules. - """ - - @internal {:next_event, :internal, nil} - - defstruct [:conn, :conn_mon, :module, :modstate, :args, :nick, :modes, :info, :error] - - def callback_mode, do: [:state_functions, :state_enter] - - @type event ::event_connected | event_nick | event_modes | event_line | event_down - - @type event_connected :: {:connected, Connection.Info.t} - @type event_nick :: {:nick, prev_nick :: String.t, new_nick :: String.t} - @type event_modes :: {:modes, changes :: String.t, prev :: list, modes :: list} - @type event_line :: {:line, line :: Irc.Parser.Line.t} - @type event_down :: {:down, Connection.error, delay :: integer} - - @type modstate :: any - @type handle_return :: {:ok, modstate()} - @type handle_call_return :: {:reply, any(), modstate()} | {:noreply, modstate()} - - @callback init(Keyword.t) :: {:ok, modstate} | any - @callback handle_event(event(), modstate()) :: handle_return() - @callback handle_info(any(), modstate()) :: handle_return() - @callback handle_cast(any(), modstate()) :: handle_return() - @callback handle_call(any(), reference(), modstate()) :: handle_call_return() - @callback stop(Connection.error, modstate) :: any() - - @type start_opt :: Connection.start_opt - | {:module, module()} - | {:conn_statem_opts, [:gen_statem.start_opt]} - @type start_ret :: {:ok, pid} | {:error, Connection.error} | :gen_statem.start_ret - - @spec start(Connection.nick, Connection.host, [start_opt], [:gen_statem.start_opt]) :: start_ret - def start(nick, host, opts \\ [], start_opts \\ []) do - :gen_statem.start(__MODULE__, [nick, host, prepare_args(opts)], start_opts) - end - - @spec start_link(Connection.nick, Connection.host, [start_opt], [:gen_statem.start_opt]) :: start_ret - def start_link(nick, host, opts \\ [], start_opts \\ []) do - :gen_statem.start_link(__MODULE__, [nick, host, prepare_args(opts)], start_opts) - end - - - @doc false - def init([args]) do - capabs = ["account-notify", "away-notify"] - args = args - |> Keyword.put(:capabs, capabs) - with \ - {:ok, conn} <- Connection.start(Keyword.get(args, :nick), Keyword.get(args, :host), args, Keyword.get(args, :conn_statem_opts, [])), - conn_mon <- Process.monitor(conn), - {:ok, module} <- Keyword.fetch(args, :module), - {:ok, modstate} <- module.init(args) - do - data = %__MODULE__{ - conn: conn, conn_mon: conn_mon, - module: module, modstate: modstate, - args: args - } - {:ok, :disconnected, data} - else - error -> {:stop, error} - end - end - - def disconnected(:enter, :connected, data) do - Logger.debug "#{inspect data} disconnected: #{inspect data.error}" - :keep_state_and_data - end - - def disconnected(:info, msg = {:irc_conn_up, _, info}, data) do - data = run_handler_event({:connected, info}, data) - {:next_state, :connected, %__MODULE__{data | nick: info.nick, info: info, modes: info.modes, error: nil}} - end - - def disconnected(type, content, data) do - handle_common(:disconnected, type, content, data) - end - - def connected(:enter, _, data) do - Logger.debug "#{inspect data} UP" - :keep_state_and_data - end - - def connected(:info, msg = {:irc_conn_nick, _, prev_nick, new_nick}, data) do - data = run_handler_event({:nick, prev_nick, new_nick}, data) - {:keep_state, %__MODULE__{data | nick: new_nick}} - end - - def connected(:info, msg = {:irc_conn_modes, _, changes, prev, modes}, data) do - data = run_handler_event({:modes, changes, prev, modes}, data) - {:keep_state, %__MODULE__{data | modes: modes}} - end - - def connected(:info, {:irc_conn_down, _, reason, delay}, data) do - data = run_handler_event({:down, reason, delay}, data) - {:next_state, :disconnected, %__MODULE__{data | error: reason}} - end - - # TODO: REMOVE? - def connected(:info, {:irc_conn_line, _, line}, data) do - data = run_handler_event({:line, line}, data) - :keep_state_and_data - end - - def connected(type, content, data) do - handle_common(:connected, type, content, data) - end - - # TODO: Callback stop? - def error(:internal, reason, data) do - Logger.error "#{inspect data}: #{inspect reason}" - data.module.stop(reason, data.modstate) - {:stop, :normal} - end - - def handle_common(_, :info, {:irc_conn_error, _, reason}, data) do - {:next_state, :error, data, {:next_event, :internal, reason}} - end - - def handle_common(_, :enter, _, _) do - :keep_state_and_data - end - - def terminate(reason, state, data) do - Logger.error("#{inspect(data)} terminating in #{inspect(state)}: #{inspect reason}") - end - - def code_change(_old_vsn, old_state, old_data, _extra) do - {:ok, old_state, old_data} - end - - defp prepare_args(args) do - args - end - - defp run_handler_event(event, data) do - case data.module.handle_event(event, data.modstate) do - {:ok, modstate} -> %__MODULE__{data | modstate: modstate} - :ok -> data - end - end - - defimpl Inspect, for: __MODULE__ do - import Inspect.Algebra - def inspect(struct, _opts) do - concat(["#Irc.Client<", inspect(self()), ">"]) - end - end - - -end diff --git a/lib/irc/base_client/capabs.ex b/lib/irc/base_client/capabs.ex deleted file mode 100644 index e69de29..0000000 diff --git a/lib/irc/client.ex b/lib/irc/client.ex index 1c4b0c5..d8a7b77 100644 --- a/lib/irc/client.ex +++ b/lib/irc/client.ex @@ -1,48 +1,269 @@ defmodule Irc.Client do - alias Irc.BaseClient - @behaviour Irc.BaseClient + @behaviour :gen_statem require Logger + alias Irc.Parser.Line + alias Irc.Connection + require Irc.Parser.Numeric + import Irc.Parser.Numeric @moduledoc """ - Fully-featured IRC client. + Fully-featured IRC Client. - The client uses message passing. + Events emanating from the client are sent to the owner PID, `t:message/0`, and commands can be sent using `command/3`. The response is asynchronous. + If a command is sent when the client is disconnected, it will be postponed until the client is connected again. + + Known IRC users are cached into `Irc.Client.UserCache`. Depending on the connection capabilities, data returned by the cache may not be up-to-date, and can be retreived with `whois/2`. + + ## Extending + + Most of the commands and capabs are implemented in separate modules. Additional modules can be set in the start options. See `Irc.Client.Capabs`. """ - @type message :: Irc.BaseClient.event - @type start_ret :: BaseClient.start_ret + @commands [ + Irc.Client.Command.Join, + Irc.Client.Command.Who, + Irc.Client.Command.Names, + Irc.Client.Command.Away, + Irc.Client.Command.Chghost, + Irc.Client.Command.Account, + ] + + @internal {:next_event, :internal, nil} + + defstruct [:conn, :conn_mon, :module, :modstate, :args, :nick, :modes, :info, :error, :commands, :buffer] + + def callback_mode, do: [:state_enter] + + @type message :: {:irc_client, pid(), event} + + @type event :: event_connected | event_nick | event_modes | event_line | event_down + | Irc.Client.Command.Join.t + | Irc.Client.Command.Names.t + | Irc.Client.Command.Who.t + | Irc.Client.Command.Away.t + | Irc.Client.Command.Chghost.t + | Irc.Client.Command.AccountNotify.t - @spec start(Irc.Connection.nick, Irc.Connection.host, [BaseClient.start_opt], [:gen_statem.start_opt]) :: start_ret + @type event_connected :: {:connected, Connection.Info.t} + @type event_nick :: {:nick, {prev_nick :: String.t, new_nick :: String.t}} + @type event_modes :: {:modes, {changes :: String.t, prev :: list, modes :: list}} + @type event_line :: {:line, line :: Irc.Parser.Line.t} + @type event_down :: {:down, {Connection.error, delay :: integer}} + + + @type start_opt :: Connection.start_opt + | {:module, module() | Irc.Client} + | {:commands, [module()]} + | {:conn_statem_opts, [:gen_statem.start_opt]} + @type start_ret :: {:ok, pid} | {:error, Connection.error} | :gen_statem.start_ret + + @spec start(Connection.nick, Connection.host, [start_opt], [:gen_statem.start_opt]) :: start_ret def start(nick, host, opts \\ [], start_opts \\ []) do :gen_statem.start(__MODULE__, [nick, host, prepare_args(opts)], start_opts) end - @spec start_link(Irc.Connection.nick, Irc.Connection.host, [BaseClient.start_opt], [:gen_statem.start_opt]) :: start_ret + @spec start_link(Connection.nick, Connection.host, [start_opt], [:gen_statem.start_opt]) :: start_ret def start_link(nick, host, opts \\ [], start_opts \\ []) do :gen_statem.start_link(__MODULE__, [nick, host, prepare_args(opts)], start_opts) end - @doc false - def init(_) do - Logger.debug("CLIENT initiated") - {:ok, %{process: nil}} + def command(pid, command, args) do + ref = make_ref() + Process.send(pid, {:irc_command, command, ref, args}) + ref end - @doc false - def handle_event(event, state) do - Logger.debug("CLIENT EVENT: #{inspect event}") - :ok + @doc "Whois an user. Depending on the connection capabilities and cache contents, a `WHOIS` irc command may not be executed; unless `cached: false`." + @type whois_opt :: {:cached, true | false} + @spec whois(pid(), String.t, Keyword.t) :: Irc.User.t() + def whois(pid, nick, opts \\ []) do + end + + def call_command(pid, command, args, opts \\ []) do + timeout = Keyword.get(opts, :timeout, 10_000) + ref = command(pid, command, args) + receive do + {:irc_command, command, result} -> result + after + timeout -> {:error, :timeout} + end end @doc false - def stop(reason, state) do - Logger.debug("CLIENT: Stopping, #{inspect reason}") + def init([args]) do + with \ + commands <- init_commands(args), + capabs <- Enum.uniq(commands.capabs ++ Keyword.get(args, :capabs, [])), + args <- Keyword.put(args, :capabs, capabs), + {:ok, conn} <- Connection.start(Keyword.get(args, :nick), Keyword.get(args, :host), args, Keyword.get(args, :conn_statem_opts, [])), + conn_mon <- Process.monitor(conn) + do + Logger.info("Commands registered: #{inspect commands}") + data = %__MODULE__{ + conn: conn, conn_mon: conn_mon, + args: args, + commands: commands + } + {:ok, :disconnected, data} + else + error -> {:stop, error} + end end - defp prepare_args(opts) do - opts - |> Keyword.put_new(:module, __MODULE__) + def handle_event(:enter, :connected, :disconnected, data) do + Logger.debug "#{inspect data} disconnected: #{inspect data.error}" + :keep_state_and_data end -end + def handle_event(:info, msg = {:irc_conn_up, _, info}, :disconnected, data) do + data = run_handler_event({:connected, info}, data) + {:next_state, :connected, %__MODULE__{data | nick: info.nick, info: info, modes: info.modes, error: nil}} + end + + # + # --- Connected + # + + def handle_event(:enter, _, :connected, data) do + Logger.debug "#{inspect data} UP" + :keep_state_and_data + end + + def handle_event(:info, msg = {:irc_conn_nick, _, prev_nick, new_nick}, :connected, data) do + data = run_handler_event({:nick, prev_nick, new_nick}, data) + {:keep_state, %__MODULE__{data | nick: new_nick}} + end + + def handle_event(:info, msg = {:irc_conn_modes, _, changes, prev, modes}, :connected, data) do + data = run_handler_event({:modes, changes, prev, modes}, data) + {:keep_state, %__MODULE__{data | modes: modes}} + end + + def handle_event(:info, {:irc_conn_down, _, reason, delay}, :connected, data) do + data = run_handler_event({:down, reason, delay}, data) + {:next_state, :disconnected, %__MODULE__{data | error: reason}} + end + + def handle_event(:info, {:irc_command, command, args}, :connected, data) do + if module = get_in(data, [:commands, :user, command]) do + {next, actions} = case module.handle_command(command, args, data.info) do + {next, next_arg} -> {{next, next_arg}, []} + {next, next_arg, actions} -> {{next, next_arg}, List.wrap(actions)} + next when is_atom(next) -> {{next, nil}, []} + end + + data = handle_actions(actions, data) + + case next do + {:buffer, acc} -> {:next_state, {:buffer, module}, %__MODULE__{data | buffer: acc}} + end + else + data = run_handler_event({:irc_command, command, {:error, :unsupported_command, command}}, data) # MEH? + {:keep_state, data} + end + end + + # DOES NOT HANDLE ERROR (actions can just return data now) + defp handle_actions([{:send, line} | rest], data) do + case Connection.sendline(data.conn, line) do + :ok -> handle_actions(rest, data) + error -> {:next_state, :disconnected, %__MODULE__{data | error: error}} + end + data + end + defp handle_actions([{:event, event} | rest], data) do + run_handler_event(event, data) + end + defp handle_actions([], data), do: data + + # TODO: REMOVE? + def handle_event(:info, {:irc_conn_line, _, line}, :connected, data) do + data = run_handler_event({:line, line}, data) + :keep_state_and_data + end + + # + # --- Buffer + # + + def handle_event(:info, {:irc_conn_line, _, line}, {:buffer, module}, data) do + {next, actions} = case module.handle_buffer(line, data.buffer, data.info) do + {next, next_arg} -> {{next, next_arg}, []} + {next, next_arg, actions} -> {{next, next_arg}, List.wrap(actions)} + next when is_atom(next) -> {{next, nil}, []} + end + + data = handle_actions(actions, data) + + case next do + {:finish, _} -> {:next_state, :connected, %__MODULE__{data | buffer: nil}} + {:buffer, acc} -> {:keep_state, %__MODULE__{data | buffer: acc}} + end + end + + # TODO: Callback stop? + def handle_event(:internal, reason, :error, data) do + Logger.error "#{inspect data}: #{inspect reason}" + data.module.stop(reason, data.modstate) + {:stop, :normal} + end + + def handle_event(:info, {:irc_conn_error, _, reason}, _, data) do + {:next_state, :error, data, {:next_event, :internal, reason}} + end + + def handle_event(:enter, _, _, _) do + :keep_state_and_data + end + + def terminate(reason, state, data) do + Logger.error("#{inspect(data)} terminating in #{inspect(state)}: #{inspect reason}") + end + + def code_change(_old_vsn, old_state, old_data, _extra) do + {:ok, old_state, old_data} + end + + defp prepare_args(args) do + args + end + + defp run_handler_event(event, data) do + case data.module.handle_event(event, data.modstate) do + {:ok, modstate} -> %__MODULE__{data | modstate: modstate} + :ok -> data + end + end + + defp init_commands(args) do + commands = @commands ++ Keyword.get(args, :commands, []) + Enum.reduce(commands, %{server: %{}, user: %{}, capabs: []}, fn(module, acc) -> + {server_commands, user_commands, capabs} = module.init() + + get_value = fn(option) -> + case option do + str when is_binary(str) -> [str] + nil -> [] + list when is_list(list) -> list + end + end + + reduce_fun = fn(command, acc) -> Map.put(acc, command, module) end + + server = Enum.reduce(List.wrap(server_commands), acc.server, reduce_fun) + user = Enum.reduce(List.wrap(user_commands), acc.user, reduce_fun) + capabs = List.concat(acc.capabs, List.wrap(capabs)) + %{server: server, user: user, capabs: capabs} + end) + end + + defimpl Inspect, for: __MODULE__ do + import Inspect.Algebra + def inspect(struct, _opts) do + concat(["#Irc.Client<", inspect(self()), ">"]) + end + end + + +end diff --git a/lib/irc/client/command.ex b/lib/irc/client/command.ex new file mode 100644 index 0000000..a84324f --- /dev/null +++ b/lib/irc/client/command.ex @@ -0,0 +1,50 @@ +defmodule Irc.Client.Command do + + @moduledoc """ + Implementations of IRC protocol commands and capabilities. + + The Capabs modules are used by the BaseClient to implement everything. + + Capabs modules are mostly stateless, except when they enter the buffering FSM state. + + It is recommended that event names matches the command name, to help awaiting response. + """ + + @doc """ + Returns which server-initiated and client-initiated commands the module supports, and IRCv3 capabs to be requested to the server. + """ + @callback init(Keyword.t) :: {commands :: [String.t], + client_commands :: [atom], + capabs :: [String.t]} + + @type buffer :: any() + @type event :: {client_command :: atom | atom, any()} + + @type buffer_ret :: :buffer | {:buffer, buffer()} + @type line_return :: :ok | {:ok, [action]} | {:error, any()} | buffer_ret + @type buffer_return :: buffer_ret | line_return | :postpone + + @type send_action :: {:send, Line.t()} + @type event_action :: {:event, event()} + @type action :: send_action | event_action + + @doc """ + Handles an incoming line. + """ + @callback handle_line(Irc.Line.t(), Irc.Connection.t()) :: line_return + + @doc """ + Handles an incoming line when in buffering state. + + Returning anything else than `:buffer` or `:postpone` will exit the buffering state. + + Postponed lines will be treated as usual (per their respective module) once the buffering state exits. + """ + @callback handle_buffer(Irc.Line.t(), buffer, Irc.Connection.t()) :: buffer_return + + @doc """ + Handles a user requested command. + """ + @callback handle_command(client_command :: atom, args :: list, Irc.Connection.t()) :: line_return + +end diff --git a/lib/irc/client/command/account.ex b/lib/irc/client/command/account.ex new file mode 100644 index 0000000..c5ec1a9 --- /dev/null +++ b/lib/irc/client/command/account.ex @@ -0,0 +1,18 @@ +defmodule Irc.Client.Command.Account do + alias Irc.Parser.Line + + @type t :: logout :: {:account, Irc.Mask.t} | login :: {:account, Irc.Mask.t, String.t} + + def init() do + {"ACCOUNT", nil, "account-notify"} + end + + def handle_line(%Line{command: "ACCOUNT", source: target, args: ["*"]}) do + {:event, {:account, target}} + end + + def handle_line(%Line{command: "ACCOUNT", source: target, args: [account_name]}) do + {:event, {:account, target, account_name}} + end + +end diff --git a/lib/irc/client/command/away.ex b/lib/irc/client/command/away.ex new file mode 100644 index 0000000..3b6b38e --- /dev/null +++ b/lib/irc/client/command/away.ex @@ -0,0 +1,25 @@ +defmodule Irc.Client.Command.Away do + alias Irc.Parser.Line + + @type t :: away :: {:away, Irc.Mask.t, String.t} | unaway :: {:away, Irc.Mask.t} + + def init() do + {"AWAY", :away, "away-notify"} + end + + def handle_command(:away, args, _) do + command = case args do + [] -> ['AWAY'] + [message] -> ['AWAY :', message] + end + {:send, command} + end + + def hanle_line(%Line{command: "AWAY", source: source, args: args}, _) do + case args do + [] -> {:event, {:away, source}} + [message] -> {:event, {:away, {source, message}}} + end + end + +end diff --git a/lib/irc/client/command/chghost.ex b/lib/irc/client/command/chghost.ex new file mode 100644 index 0000000..2b4adab --- /dev/null +++ b/lib/irc/client/command/chghost.ex @@ -0,0 +1,15 @@ +defmodule Irc.Client.Command.Chghost do + alias Irc.Parser.Line + + @type t :: {:chghost, old_mask :: Irc.Mask.t, new_mask :: Irc.Mask.t} + + def init() do + {"CHGHOST", nil, "chghost"} + end + + def handle_line(%Line{source: old_mask, args: [new_user, new_host]}, _) do + new_mask = %Irc.Mask{old_mask | user: new_user, host: new_host} + {:event, {:chghost, old_mask, new_mask}} + end + +end diff --git a/lib/irc/client/command/invite.ex b/lib/irc/client/command/invite.ex new file mode 100644 index 0000000..833c2fc --- /dev/null +++ b/lib/irc/client/command/invite.ex @@ -0,0 +1,21 @@ +defmodule Irc.Client.Command.Invite do + alias Irc.Parser.Line + + @type invite :: {:invite, channel :: String.t(), inviter :: String.t(), invited_nick :: String.t()} + @type invited :: {:invite, channel :: String.t, inviter :: Irc.Mask.t} + @type t :: invite | invited + + def init() do + {"INVITE", :invite, "invite-notify"} + end + + def handle_line(%Line{command: "INVITE", source: inviter, args: [invited, channel]}) do + {:event, {:invite, channel, inviter, invited}} + end + + def handle_line(%Line{command: "INVITE", source: inviter, args: [channel]}) do + {:event, {:invite, channel, inviter}} + end + +end + diff --git a/lib/irc/client/command/join.ex b/lib/irc/client/command/join.ex new file mode 100644 index 0000000..f4728c7 --- /dev/null +++ b/lib/irc/client/command/join.ex @@ -0,0 +1,40 @@ +defmodule Irc.Client.Command.Join do + alias Irc.Parser.Line + require Line + import Line + + @type t :: {:join, channel :: String.t()} | {:join, channel :: String.t(), Irc.User.t()} + + def init(_args) do + {"JOIN", :join, ["extended-join"]} + end + + # An user joined + def handle_line(line = %Line{command: "JOIN", source: source, args: [channel | args]}, conn) do + if Line.self?(line, conn) do + {:event, {:join, channel}} + else + user = Irc.User.from_mask(source) + user = case args do + [] -> user + [account, name] -> + account = if account != "*", do: account + {channel, %Irc.User{user | account: account, name: name}} + end + {:event, {:join, channel, user}} + end + end + + # Join a channel + def handle_command(:join, [channel], _conn_info) do + {:send, ['JOIN', channel]} + end + + # Outdated! + def handle_buffer(%Line{command: "JOIN", source: %Irc.Mask{nick: nick}, args: [channel]}, buffer = %{channel: channel}, %Irc.Connection{nick: nick}) do + {:finish, nil, {:event, {:join, channel}}} + end + + def handle_buffer(_, _), do: :postpone + +end diff --git a/lib/irc/client/command/names.ex b/lib/irc/client/command/names.ex new file mode 100644 index 0000000..b5a84a6 --- /dev/null +++ b/lib/irc/client/command/names.ex @@ -0,0 +1,22 @@ +defmodule Irc.Client.Command.Names do + alias Irc.Parser.Line + require Irc.Parser.Numeric + import Irc.Parser.Numeric + + def init() do + {[rpl_NAMREPLY(), rpl_ENDOFNAMES()], :names, ["userhost-in-names"]} + end + + def handle_line(%Line{command: rpl_NAMREPLY()}) do + :buffer + end + + def handle_line(%Line{command: rpl_ENDOFNAMES(), args: [_, target | _]}) do + :finish + end + + def handle_buffer(buffer = [%Line{args: [_, target | _]} | _]) do + {:event, {:names, target, buffer}} + end + +end diff --git a/lib/irc/client/command/who.ex b/lib/irc/client/command/who.ex new file mode 100644 index 0000000..1654ef6 --- /dev/null +++ b/lib/irc/client/command/who.ex @@ -0,0 +1,62 @@ +defmodule Irc.Client.Command.Who do + alias Irc.Parser.Line + alias Irc.User + use Irc.Parser.Numeric + + @type t :: {:who, target :: String.t, [Irc.User.t]} + + def init(_) do + {nil, "WHO", nil} + end + + def handle_command("WHO", [target], conn) do + args = if Irc.Connection.supports?(conn, "whox") do + " nuhs%cuhsnfdar" + end + {:buffer, %{target: target, acc: [], error: nil}, {:send, ['WHO ', target, args]}} + end + + @errors [err_NOSUCHSERVER(), rpl_TRYAGAIN()] + def handle_buffer(%Line{command: error, args: args}, buffer, _conn) when error in @errors do + #? + {:buffer, %{buffer | error: {error, args}}} + end + + def handle_buffer(%Line{command: rpl_ENDOFWHO()}, buffer, _conn) do + result = if buffer.error do + {:error, buffer.error} + else + {:ok, buffer.acc} + end + {:finish, nil, {:event, {:who, buffer.target, result}}} + end + + def handle_buffer(%Line{command: rpl_WHOREPLY(), args: args}, buffer, conn) do + case args do + [_, c, u, h, s, n, f, d | a_r] -> + {a,r} = case a_r do + [a, r] -> {a, r} + [r] -> {nil, r} + end + {modes, _} = Irc.Parser.Prefix.parse(f, conn) + chanmodes = %{buffer.target => modes} + user = %Irc.User{ + user: u, + host: h, + server: s, + nick: n, + account: a, + name: r, + channels: [buffer.target], + chanmodes: chanmodes + } + + _ -> + {:finish, nil} # ERROFFEL + end + {:buffer, %{buffer | acc: [args | buffer.acc]}} + end + + def handle_buffer(_, _), do: :postpone + +end diff --git a/lib/irc/client/user_cache.ex b/lib/irc/client/user_cache.ex new file mode 100644 index 0000000..2653630 --- /dev/null +++ b/lib/irc/client/user_cache.ex @@ -0,0 +1,31 @@ +defmodule Irc.Client.UserCache do + + # tuple: {nick, user} + + def init(args) do + opts = [{:read_concurrency, true}, :protected] + :ets.new(__MODULE__, opts) + end + + def lookup(ets, nick) do + case :ets.lookup(ets, nick) do + [{^nick, user}] -> user + _ -> nil + end + end + + def change(ets, nick, changes) do + initial = if user = lookup(ets, nick), do: user, else: %Irc.User{nick: nick} + user = User.change(initial, changes) + put(ets, nick, user) + end + + defp put(ets, original_nick, user) do + tuple = {user.nick, user} + if original_nick != user.nick do + true = :ets.delete(ets, original_nick) + end + :ets.insert(ets, tuple) + end + +end diff --git a/lib/irc/connection.ex b/lib/irc/connection.ex index f43fca7..052141c 100644 --- a/lib/irc/connection.ex +++ b/lib/irc/connection.ex @@ -11,10 +11,39 @@ defmodule Irc.Connection do @reply_timeout :timer.seconds(1) @cap_timeout :timer.seconds(15) @registration_timeout :timer.seconds(30) - @support_capabs ["sasl", "cap-notify"] + @connection_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] + @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`, 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], []) + ``` + + 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`! + """ + + defstruct [:server, :nick, :modes, :capabs, :version, :umodes, :cmodes, :cmodes_with_params, :isupport, :motd] @typedoc "Connection Information state" @type t :: %__MODULE__{ @@ -22,25 +51,24 @@ defmodule Irc.Connection do 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(), + version: String.t, + umodes: String.t, + cmodes: String.t, + cmodes_with_params: String.t, + isupport: Irc.Parser.Isupport.t(), 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() + | 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." @@ -58,61 +86,81 @@ defmodule Irc.Connection do | {: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. + * port (default 6667, 6697 if tls), + * reconnect (automatic/backoff reconnection), + * [batch][batch] + * [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. + """ @type start_opt :: {:user, nil | String.t} | {:name, nil | String.t} | {:pass, nil | String.t} - | {:capabs, [String.t]} - | {:port, 6667 | Integer.t} + | {:port, 6667 | 6697 | Integer.t} | {:tls, false | true} - | {:inet, nil | 4 | 6} - | {:iface, nil | String.t} - | {:ip, nil | String.t} + | {:sts, start_opt_sts()} | {:reconnect, true | false} - | {:process, pid} - | {:tcp_opts, [:gen_tcp.connect_option]} - | {:tls_opts, [:ssl.connect_option]} + | {: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()} - @moduledoc """ - Lightweight implementation of an IRC Connection handling only the basics: + @typedoc """ + IRCv3 [Strict Transport Security]() - * the socket and reconnections, - * registration process, - * sasl authentication, - * capabilities negotiation, - * ping, - * nick and user mode changes. + 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 - Events and lines are sent to calling PID. + @typedoc """ + [SASL][sasl] Authentication options - ## Usage + [sasl]: https://ircv3.net/specs/extensions/sasl-3.1.html + """ + @type start_opt_sasl :: %{mechanism :: String.t => [mechanism_param() :: String.t]} - The client is started using `start/4` or `start_link/4`: + @typedoc """ + IRCv3 [Batch][batch] support. - ```elixir - {:ok, Pid} = Irc.Connection.start("ExIrcTest", "irc.random.sh", [tls: true, port: 6697], []) - ``` + Enabled by default. If negotiated, batched lines are sent with the `t:msg_batch/0` message. - Messages will be sent to your PID, see `t:message/0` for the possible types. + If the capability wasn't negotiated or disabled, individual `t:msg_line/0` messages will be sent. - You can send lines using `sendline/3`, and disconnect with `disconnect/3`. + [batch]: https://ircv3.net/specs/extensions/batch-3.2 + """ + @type start_opt_batch :: true | false - Status and information about the connection (`t:t/0`) can be requested with `info/1`. + @typedoc """ + IRCv3 [echo-message](https://ircv3.net/specs/extensions/echo-message-3.2) support. - If you are looking for a more featured client, you probably want `Irc.Client`! + 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. + + ``` + {"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 @@ -187,6 +235,18 @@ defmodule Irc.Connection do 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 + Enum.member?(capabs, feature) + end + + @spec supports?(String.t, 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 @@ -204,22 +264,23 @@ defmodule Irc.Connection do defmodule State do @moduledoc false defstruct [:args, - :backoff, :socket, :sockmodule, {:tries, 0}, + :backoff, :socket, :sockmodule, :process, :monitor, :nick, :server, - :server_version, - :server_umodes, - :server_cmodes, - :server_cmodes_with_params, + :version, + :umodes, + :cmodes, + :cmodes_with_params, :welcomed, + :sts, :port, :tls, + {:batches, %{}}, {:errored, false}, {:isupport, %{}}, {:modes, []}, {:server_capabs, []}, - {:req_capabs, []}, + {:client_capabs, []}, {:capabs, []}, - {:buffer, []}, {:motd, []}, {:welcome, []}, ] @@ -235,11 +296,31 @@ defmodule Irc.Connection do process = Keyword.get(args, :process, nil) monitor = Process.monitor(process) with \ - {:ok, processed_args} <- process_args(args), + {:ok, args} <- process_args(args), backoff <- :backoff.init(backoff_min, backoff_max) do + {tls, sts, port} = if !args.tls && args.sts do + case Irc.STS.lookup(args.host, args.port) do + {true, port} -> {true, true, port} + {false, port} -> {false, false, port} + end + else + {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() data = %State{ - args: processed_args, + args: args, + sts: sts, port: port, tls: tls, + client_capabs: client_capabs, process: process, monitor: monitor, backoff: backoff @@ -254,14 +335,14 @@ defmodule Irc.Connection do # TCP Open @doc false - def connect(:internal, _, data = %{args: %{tls: true}}) do + def connect(:internal, _, data = %{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 + options = [{:active, true}, {:packet, :line}] ++ data.args.connect_opts + case :gen_tcp.connect(String.to_charlist(data.args.host), data.port, options) do {:ok, socket} -> {_, backoff} = :backoff.succeed(data.backoff) {:next_state, :connected, %State{data | backoff: backoff}, {:next_event, :internal, {:gen_tcp, socket}}} @@ -279,8 +360,8 @@ defmodule Irc.Connection do @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 + options = [{:active, true}, {:packet, :line}] ++ data.args.connect_opts + case :ssl.connect(String.to_charlist(data.args.host), data.port, options) do {:ok, socket} -> {_, backoff} = :backoff.succeed(data.backoff) {:next_state, :connected, %State{data | backoff: backoff}, {:next_event, :internal, {:ssl, socket}}} @@ -295,13 +376,43 @@ defmodule Irc.Connection do @doc false def connected(:internal, {module, socket}, data) do - {:next_state, :cap_ls, %State{data | sockmodule: module, socket: socket}, @internal} + next = if Map.get(data.args, :webirc) do + :webirc + else + :cap_ls + end + + {:next_state, next, %State{data | sockmodule: module, socket: socket}, @internal} end def connected(type, content, data) do handle_common(:connected, type, content, data) end + # Registration: WEBIRC + # https://ircv3.net/specs/extensions/webirc + + @doc false + def webirc(:internal, _, data = %{args: args}) do + {password, gateway, hostname, ip, options} = Keyword.get(args, :webirc) + 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 webirc(type, content, data) do + handle_common(:webirc, type, content, data) + end + # Registration: LIST CAPABS @doc false @@ -321,18 +432,15 @@ defmodule Irc.Connection do {: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? + {:next_state, :cap_sts, %State{data | server_capabs: capabs}, @internal} 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} + {:next_state, :cap_sts, data, @internal} end def cap_ls(type, content, data) do @@ -340,6 +448,41 @@ defmodule Irc.Connection do handle_common(:cap_ls, type, content, data) end + # REGISTRATION: Check for STS + + @doc false + def cap_sts(:internal, _, data = %{args: %{sts: true}}) do + sts? = Enum.find(data.capabs, fn(capab) -> String.starts_with?(capab, "sts=") end) + if sts? do + "sts="<>rules = sts? + rules = Map.reduce(String.split(rules, ","), %{}, fn(k_v, acc) -> + [k, v] = String.split(k_v, "=") + Map.put(acc, k, v) + end) + port = Map.get(rules, "port") + duration = Map.get(rules, "duration") + Irc.STS.witness(data.args.host, data.args.port, port, Map.get(rules, "duration")) + if !data.tls && port && duration do + Logger.info("#{inspect data} STS advertised, reconnecting to tls port #{port}") + socksend(data, ['QUIT :STS, Reconnecting']) + data.sockmodule.close(data.socket) + {:next_state, :connect, %State{data | sts: true, port: port, tls: true, socket: nil, sockmodule: nil}, @internal} + else + {:next_state, :reg_pass, %State{data | sts: true}, @internal} + end + else + {:next_state, :reg_pass, data, @internal} + end + end + + 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 @@ -432,8 +575,7 @@ defmodule Irc.Connection do 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) + req = Enum.filter(data.client_capabs, fn(capab) -> Enum.member?(data.server_capabs, capab) end) |> Enum.uniq() |> Enum.join(" ") case socksend(data, ['CAP REQ :', String.to_charlist(req)]) do @@ -493,8 +635,8 @@ defmodule Irc.Connection 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} + 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}} l = %Line{command: rpl_ISUPPORT(), args: [_ | isupport]} -> supports = Irc.Parser.Isupport.parse(isupport, data.isupport) @@ -548,7 +690,7 @@ defmodule Irc.Connection do 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.take([:nick, :modes, :server, :version, :umodes, :cmodes, :cmodes_with_params, :capabs, :isupport, :motd]) |> Map.put(:__struct__, __MODULE__) end @@ -652,6 +794,31 @@ defmodule Irc.Connection do {: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)}} + 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)}) + else + for l <- Enum.reverse(lines), do: send(data.process, {:irc_conn_line, self(), l}) + 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]) + {:keep_state, %State{data | batches: Map.put(data.batches, batch, batch_data)}} + else + :ignore + end + end + def process_line(line, _) do :ignore end @@ -806,6 +973,9 @@ defmodule Irc.Connection do end # Send helpers. + defp socksend(data, line = %Line{}) do + socksend(data, [Line.encode(line, info(data))]) + end 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]]) @@ -830,15 +1000,10 @@ defmodule Irc.Connection do # 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) + sts = Keyword.get(args, :sts, nil) || true + default_port = if tls, do: 6697, else: 6667 + port = Keyword.get(args, :port, nil) || default_port connect_timeout = Keyword.get(args, :connect_timeout, @connect_timeout) nick = Keyword.get(args, :nick, nil) user = Keyword.get(args, :user, nick) || nick @@ -846,28 +1011,26 @@ defmodule Irc.Connection do 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, []) || [] + batch = Keyword.get(args, :batch, true) + connect_opts = Keyword.get(args, :connect_opts, []) || [] with \ {:host, {:ok, host}} <- {:host, parse_host(host)}, - {:port, {:ok, port}} <- {:port, parse_port(port)}, - {:bind, {:ok, bind}} <- {:bind, parse_bind(ip, ifaddr)} + {:port, {:ok, port}} <- {:port, parse_port(port)} do {:ok, %{ host: host, - port: port, - bind: bind, tls: tls, + sts: sts, + port: port, capabs: capabs, - inet: inet, nick: nick, user: user, name: name, pass: pass, reconnect: reconnect, - tcp_opts: tcp_opts, - tls_opts: tls_opts, + batch: batch, + connect_opts: connect_opts, connect_timeout: connect_timeout }} else @@ -885,22 +1048,6 @@ defmodule Irc.Connection do 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 diff --git a/lib/irc/mask.ex b/lib/irc/mask.ex new file mode 100644 index 0000000..5106c26 --- /dev/null +++ b/lib/irc/mask.ex @@ -0,0 +1,52 @@ +defmodule Irc.Mask do + + @type t :: %__MODULE__{nick: nil | String.t(), user: nil | String.t(), host: nil | String.t(), server: nil | String.t()} + defstruct [:nick, :user, :host, :server] + + @spec parse(String.t) :: {:ok, t()} | {:error, :invalid_mask} + def parse(string) do + cond do + String.contains?(string, "!") -> parse_user_mask(string) + String.contains?(string, ".") -> parse_server_mask(string) + true -> {:error, :invalid_mask} + end + end + + def new(nick, user, host) do + %__MODULE__{nick: nick, user: user, host: host} + end + + def to_string(%__MODULE__{server: nil, nick: nick, user: user, host: host}) do + Enum.join([nick, "!", user, "@", host]) + end + + def to_string(%__MODULE__{server: server}), do: server + + defp parse_user_mask(string) do + case String.split(string, ~r/[!@]/, parts: 3) do + [nick, user, host] -> + {:ok, %__MODULE__{nick: nick, user: user, host: host}} + _ -> + {:error, :invalid_mask} + end + end + + defp parse_server_mask(string) do + {:ok, %__MODULE__{server: string}} + end + + defimpl String.Chars, for: __MODULE__ do + @moduledoc false + defdelegate to_string(struct), to: Irc.Mask + end + + defimpl Inspect, for: __MODULE__ do + @moduledoc false + import Inspect.Algebra + + def inspect(struct, _opts) do + concat(["#Irc.Mask<", Irc.Mask.to_string(struct), ">"]) + end + end + +end diff --git a/lib/irc/parser.ex b/lib/irc/parser.ex index 0224c16..67bb789 100644 --- a/lib/irc/parser.ex +++ b/lib/irc/parser.ex @@ -11,4 +11,10 @@ defmodule Irc.Parser do """ + def capabs, do: ["multi-prefix", + "message-tags", + "account-tag", + "server-time" + ] + end diff --git a/lib/irc/parser/isupport.ex b/lib/irc/parser/isupport.ex index 9dd7f30..dda4220 100644 --- a/lib/irc/parser/isupport.ex +++ b/lib/irc/parser/isupport.ex @@ -1,8 +1,17 @@ defmodule Irc.Parser.Isupport do @moduledoc """ - ISUPPORT list parser + ISUPPORT list parser. """ + @typedoc "Server ISUPPORT." + @type t :: %{key => value} + @type key :: String.t + @type value :: true | Integer.t() | Map.t() | prefix_map + + @type prefix :: String.t() + @type mode :: String.t() + @type prefix_map :: %{prefix() => mode()} + def parse(list, acc \\ %{}) do list = List.flatten(list) Enum.reduce(list, acc, fn(entry, acc) -> @@ -13,25 +22,36 @@ defmodule Irc.Parser.Isupport do else Map.put(acc, entry, true) end - [entry, value] -> Map.put(acc, entry, parse_value(value)) + [entry, value] -> Map.put(acc, entry, parse_value(entry, value)) end end) end - defp parse_value(value) do + defp parse_value(entry, value) do if String.contains?(value, ":") do Enum.reduce(String.split(value, ","), %{}, fn(key_and_value, acc) -> case String.split(key_and_value, ":", parts: 2) do [key] -> Map.put(acc, key, nil) - [key, value] -> Map.put(acc, key, format_value(value)) + [key, value] -> Map.put(acc, key, format_value(key, value)) end end) else - format_value(value) + format_value(entry, value) + end + end + + def format_value("PREFIX", value) do + case String.split(value, ")", parts: 2) do + ["("<>modes, prefixes] -> + modes = String.split(modes, "", trim: true) + prefixes = String.split(prefixes, "", trim: true) + Enum.reduce(Enum.with_index(modes), %{}, fn({mode, position}, acc) -> + Map.put(acc, Enum.at(prefixes, position), mode) + end) end end - def format_value(value) do + def format_value(key, value) do case Integer.parse(value) do {i, ""} -> i _ -> value diff --git a/lib/irc/parser/line.ex b/lib/irc/parser/line.ex index 3ba2016..17c381c 100644 --- a/lib/irc/parser/line.ex +++ b/lib/irc/parser/line.ex @@ -1,24 +1,87 @@ defmodule Irc.Parser.Line do @moduledoc """ - IRC line parser + IRC line parser/encoder """ - defstruct tags: %{}, source: nil, command: nil, args: [] + @type t :: %__MODULE__{ + tags: Map.t(), + source: Irc.Mask.t() | String.t() | nil, + command: String.t(), + args: list() + } + defstruct __private__: %{}, tags: %{}, source: nil, command: nil, args: [] + @spec parse(string) :: t() @doc "Parse a server-to-client line." def parse(line) do {tags, rest} = parse_tags(line) {source, rest} = parse_source(rest) {command, rest} = parse_cmd(rest) args = parse_args(rest) - %__MODULE__{tags: tags, source: source, command: command, args: args} + private = %{ + at: DateTime.utc_now() + } + %__MODULE__{__private__: private, tags: tags, source: source, command: command, args: args} + end + + @spec encode(t()) :: String.t + @doc "Encode a line" + def encode(line = %__MODULE__{}, conn \\ %Irc.Connection{}) do + [line.source, line.tags, line.command, line.args] + src = if line.source, do: ":"<>to_string(line.source) + tags = Enum.reduce(line.tags, [], fn({k,v}, acc) -> ["#{k}=#{v}" | acc] end) + |> Enum.join(";") + tags = if Enum.member?(conn.capabs || [], "message-tags") && tags != "", do: "@"<>tags + [tags, src, line.command, encode_args(line.args)] + |> Enum.filter(fn(x) -> x end) + |> Enum.join(" ") + end + + def new(command, args \\ [], tags \\ %{}) do + %__MODULE__{command: command, args: args, tags: tags, __private__: %{at: DateTime.utc_now()}} + end + + @doc "Returns the line date (server time if sent, otherwise, parse time)" + @spec at(t()) :: DateTime.t() + def at(%__MODULE__{__private__: %{at: at}, tags: %{"time" => server_time}}) do + case DateTime.from_iso8601(server_time) do + {:ok, date} -> date + _ -> at + end + end + def at(%__MODULE__{__private__: %{at: at}}), do: at + def at(_), do: nil + + @spec to?(t(), Irc.Connection.t() | Irc.Mask.t() | Irc.User.t()) :: boolean + @doc "Returns true if the line is adressed to the connection." + def to?(%__MODULE__{args: [nick | _]}, %{__struct__: s, nick: nick}) when s in [Irc.Connection, Irc.Mask, Irc.User] do + true end + def to?(%__MODULE__{}, %{__struct__: s}) when s in [Irc.Connection, Irc.Mask, Irc.User], do: false + + @spec self?(t(), Irc.Connection.t() | Irc.Mask.t() | Irc.User.t()) :: boolean + @doc "Returns true if the line source is the from the given connection/mask." + def self?(%__MODULE__{source: %Irc.Mask{nick: nick}}, %Irc.Connection{nick: nick}) do + true + end + def self?(%__MODULE__{source: mask = %Irc.Mask{}}, mask = %Irc.Mask{}) do + true + end + def self?(line = %__MODULE__{source: mask = %Irc.Mask{}}, user = %Irc.User{}) do + self?(line, Irc.User.to_mask(user)) + end + def self?(%__MODULE__{source: nick}, %Irc.Connection{nick: nick}) do + true + end + def self?(%__MODULE__{}, %Irc.Connection{}), do: false + def self?(%__MODULE__{}, %Irc.User{}), do: false + def self?(%__MODULE__{}, %Irc.Mask{}), do: false # ARGS - def parse_args(input), do: parse_args(input, []) + defp parse_args(input), do: parse_args(input, []) - def parse_args(input, acc) do + defp parse_args(input, acc) do case parse_arg(input, []) do {:continue, new, rest} -> parse_args(rest, [new | acc]) {:finished, new} -> @@ -28,37 +91,37 @@ defmodule Irc.Parser.Line do end # Final argument is the only argument. - def parse_arg([?: | rest], acc) when length(acc) == 0 do + defp parse_arg([?: | rest], acc) when length(acc) == 0 do final = parse_final_arg(rest, []) {:finished, [final]} end - def parse_arg([0x20, ?: | rest], acc) do + defp parse_arg([0x20, ?: | rest], acc) do final = parse_final_arg(rest, []) {:finished, [final, acc]} end - def parse_arg([0x20 | rest], acc) do + defp parse_arg([0x20 | rest], acc) do {:continue, acc, rest} end - def parse_arg([char | rest], acc) do + defp parse_arg([char | rest], acc) do parse_arg(rest, [char | acc]) end - def parse_arg([], acc) do + defp parse_arg([], acc) do {:finished, [acc]} end - def parse_final_arg([char | rest], acc) do + defp parse_final_arg([char | rest], acc) do parse_final_arg(rest, [char | acc]) end - def parse_final_arg([], acc) do + defp parse_final_arg([], acc) do acc end - def format_args(list) when is_list(list) do + defp format_args(list) when is_list(list) do list |> Enum.map(fn(arg) -> String.trim(acc_to_str(arg)) end) |> Enum.reverse() @@ -66,64 +129,69 @@ defmodule Irc.Parser.Line do # COMMAND - def parse_cmd(input), do: parse_cmd(input, []) + defp parse_cmd(input), do: parse_cmd(input, []) - def parse_cmd([0x20 | rest], acc) do + defp parse_cmd([0x20 | rest], acc) do {acc_to_str(acc), rest} end - def parse_cmd([char | rest], acc) do + defp parse_cmd([char | rest], acc) do parse_cmd(rest, [char | acc]) end # SOURCE - def parse_source([?: | input]), do: parse_source(input, []) - def parse_source(input), do: {nil, input} + defp parse_source([?: | input]), do: parse_source(input, []) + defp parse_source(input), do: {nil, input} - def parse_source([0x20 | rest], acc) do - {acc_to_str(acc), rest} + defp parse_source([0x20 | rest], acc) do + string = acc_to_str(acc) + result = case Irc.Mask.parse(string) do + {:error, _} -> string + {:ok, mask} -> mask + end + {result, rest} end - def parse_source([char | rest], acc), do: parse_source(rest, [char | acc]) + defp parse_source([char | rest], acc), do: parse_source(rest, [char | acc]) # TAGS - def parse_tags([?@ | input]), do: parse_tags(input, []) - def parse_tags(input), do: {%{}, input} + defp parse_tags([?@ | input]), do: parse_tags(input, []) + defp parse_tags(input), do: {%{}, input} - def parse_tags(input, acc) do + defp parse_tags(input, acc) do case parse_tag(input) do {:continue, new, rest} -> parse_tags([new | acc], rest) {:finished, new, rest} -> {format_tags([new | acc]), rest} end end - def parse_tag(input) do + defp parse_tag(input) do parse_tag({[], nil}, input) end - def parse_tag({acc, nil}, [?= | rest]) do + defp parse_tag({acc, nil}, [?= | rest]) do parse_tag({acc, []}, rest) end - def parse_tag({acc, nil}, [?; | rest]) do + defp parse_tag({acc, nil}, [?; | rest]) do {:continue, {acc, true}, rest} end - def parse_tag({acc, nil}, [char | rest]) do + defp parse_tag({acc, nil}, [char | rest]) do parse_tag({[char | acc], nil}, rest) end - def parse_tag({key, acc}, [?; | rest]) do + defp parse_tag({key, acc}, [?; | rest]) do {:continue, {key, acc}, rest} end - def parse_tag({key, acc}, [0x20 | rest]) do + defp parse_tag({key, acc}, [0x20 | rest]) do {:finished, {key, acc}, rest} end - def parse_tag({key, acc}, [char | rest]) do + defp parse_tag({key, acc}, [char | rest]) do parse_tag({key, [char | acc]}, rest) end - def format_tags(list) do + defp format_tags(list) do list |> Enum.map(fn({k,v}) -> {acc_to_str(k), acc_to_str(v)} @@ -131,13 +199,36 @@ defmodule Irc.Parser.Line do |> Enum.into(Map.new) end - def acc_to_str([]), do: nil - def acc_to_str(list) when is_list(list) do + defp acc_to_str([]), do: nil + defp acc_to_str(list) when is_list(list) do list |> Enum.reverse() |> to_string() end - def acc_to_str(other), do: other + defp acc_to_str(other), do: other + + + defp encode_args(args) do + encode_args(args, []) + |> Enum.reverse() + |> Enum.join(" ") + end + + defp encode_args([arg], acc) do + [":"<>arg | acc] + end + + defp encode_args([arg | rest], acc) do + encode_args(rest, [arg | acc]) + end + + defp encode_args([], acc), do: acc + + defimpl String.Chars, for: __MODULE__ do + def to_string(line) do + Irc.Parser.Line.encode(line) + end + end end diff --git a/lib/irc/parser/numeric.ex b/lib/irc/parser/numeric.ex index 3a6e27f..413f13c 100644 --- a/lib/irc/parser/numeric.ex +++ b/lib/irc/parser/numeric.ex @@ -13,8 +13,9 @@ defmodule Irc.Parser.Numeric do defmacro __using__(_) do quote do - require Irc.Numeric - import Irc.Numeric + alias Irc.Parser.Numeric + require Irc.Parser.Numeric + import Irc.Parser.Numeric end end diff --git a/lib/irc/parser/prefix.ex b/lib/irc/parser/prefix.ex new file mode 100644 index 0000000..e446d7c --- /dev/null +++ b/lib/irc/parser/prefix.ex @@ -0,0 +1,47 @@ +defmodule Irc.Parser.Prefix do + + @moduledoc """ + Parser for prefixes. + + Supports multi-prefix. + """ + + @spec parse(String.t, Irc.Connection.t) :: {modes :: [String.t], rest :: nil | String.t} + @doc """ + If `lax` is set to true, the parser will ignore chars until it meets a known prefix. + """ + def parse(string, conn, lax \\ false) do + map = Map.get(conn.isupport, "PREFIX") || %{} + {prefixes, rest} = parse_prefix(string, Map.keys(map), lax) + modes = Enum.map(prefixes, fn(p) -> Map.get(map, p) end) + |> Enum.filter(fn(p) -> p end) + {Enum.reverse(modes), to_string(Enum.reverse(rest))} + end + + defp parse_prefix(string, all_prefixes, lax) do + parse_prefix(to_charlist(string), to_charlist(all_prefixes), [], [], lax) + end + + defp parse_prefix(all = [prefix | rest], all_prefixes, acc, ign, lax) do + prefix_str = to_string([prefix]) + cond do + Enum.member?(all_prefixes, prefix_str) -> parse_prefix(rest, all_prefixes, [prefix_str | acc], ign, lax) + lax && acc == [] -> parse_prefix(rest, all_prefixes, acc, [prefix | ign], lax) + true -> parse_lax(all, acc, []) + end + end + + defp parse_prefix([], _, acc, ign, _) do + {acc, ign} + end + + defp parse_lax([a | rest], prefixes, acc) do + parse_lax(rest, prefixes, [a | acc]) + end + + defp parse_lax([], prefixes, acc) do + {prefixes, acc} + end + +end + diff --git a/lib/irc/sts.ex b/lib/irc/sts.ex new file mode 100644 index 0000000..9980f8c --- /dev/null +++ b/lib/irc/sts.ex @@ -0,0 +1,169 @@ +defmodule Irc.STS do + @moduledoc """ + # STS Store. + + When a connection encounters a STS policy, it signals it using `witness/4`. The store will consider the policy valid indefinitely as long as + any connection are still alive for that host/port pair. Once all connections or the system stops, the latest policy expiration date will be computed. + + By default, the store is not persistent. If you wish to enable persistance, set the `:irc, :sts_cache_file` app environment. + """ + + @ets __MODULE__.ETS + # tuple {{host,port}, tls_port, period, until | true, at} + + @doc "Lookup a STS entry" + @spec lookup(host :: String.t(), port :: Integer.t()) :: {enabled :: boolean, port :: Integer.t()} + def lookup(host,port) do + with \ + [{_, port, period, until}] <- :ets.lookup(@ets, {host,port}), + true <- verify_validity(period, until) + do + {true, port} + else + [] -> {false, port} + false -> + GenServer.cast(__MODULE__, {:expired, {host,port}}) + {false, port} + end + end + + @doc """ + Signal a STS policy. + + The STS cache will consider the policy as infinitely valid as long as the calling PID is alive, + or signal a new `witness/4`. + """ + def witness(host, port, sts_port, period) do + GenServer.call(__MODULE__, {:witness, host, port, sts_port, period, self()}) + end + + @doc """ + Revoke a STS policy. This is the same as calling `witness/4` with sts_port = nil and period = nil. + """ + def revoke(host, port) do + GenServer.call(__MODULE__, {:witness, {host,port,nil,nil,self()}}) + end + + @doc "Returns all entries in the STS store" + def all() do + fold = fn(el, acc) -> [el | acc] end + :ets.foldl(fold, @ets, []) + end + + def start_link() do + GenServer.start_link(__MODULE__, [], [name: __MODULE__]) + end + + def init(_) do + ets = :ets.new(@ets, [:named_table, :protected]) + cache_file = Application.get_env(:irc, :sts_cache_file) + dets = if cache_file do + {:ok, dets} = :dets.open_file(cache_file) + true = :ets.from_dets(ets, dets) + end + {:ok, %{ets: ets, dets: dets, map: %{}}} + end + + def handle_continue(nil, state) do + {:noreply, state} + end + def handle_continue(_, state = %{dets: nil}) do + {:noreply, state} + end + def handle_continue({:write, entry}, state = %{dets: dets}) do + :ok = :dets.insert(dets, entry) + :ok = :dets.sync(dets) + {:noreply, state} + end + def handle_continue({:remove, key}, state = %{dets: dets}) do + :ok = :dets.delete(dets, key) + :ok = :dets.sync(dets) + {:noreply, state} + end + + # Duration 0 -- Policy is removed + def handle_call({:witness, host, port, _tls_port, period, pid}, from, state) when period in ["0", 0, nil] do + state = remove({host,port}, state) + {:reply, :ok, state, {:handle_continue, {:remove, {host,port}}}} + end + + # Witnessed policy. + # As long as caller PID is alive, consider the policy always valid + def handle_call({:witness, host, port, tls_port, period, pid}, _, state) do + entry = {{host,port}, tls_port, period, true} + :ets.insert(@ets, entry) + mon = Process.monitor(pid) + state = %{state | map: Map.put(state.map, pid, {mon,{host,port}})} + {:reply, :ok, state, {:handle_continue, {:write, entry}}} + end + + # Caller side encountered an expired policy, check and remove it. + def handle_cast({:expired, key}, state) do + {state, continue} = case :ets.lookup(@ets, key) do + [{_, _, period, until}] -> + if !verify_validity(period, until) do + {remove(key, state), {:remove, key}} + else + {state, nil} + end + [] -> {state, nil} + end + {:noreply, state, {:handle_continue, continue}} + end + + # A connection disconnected + def handle_info({:DOWN, _, :process, pid, _}, state) do + key = Map.get(state.map, pid) + others = Enum.filter(state.map, fn({p, {_,k}}) -> k == key && p != pid end) + state = %{state | map: Map.delete(state.map, pid)} + if key && Enum.empty?(others) do + case :ets.lookup(@ets, key) do + [{key, tls_port, period, until}] -> + until = DateTime.utc_now() |> DateTime.add(period) + entry = {key, tls_port, period, until} + :ets.insert(@ets, entry) + {:noreply, state, {:handle_continue, {:write, entry}}} + [] -> + {:noreply, state} + end + else + {:noreply, state} + end + end + + # Calculate expiration periods from time of shutdown. + def terminate(_, state) do + if state.dets do + fold = fn + ({key, _tls_port, period, true}, acc) -> + until = DateTime.utc_now() |> DateTime.add(period) + [{key, until} | acc] + (_, acc) -> acc + end + for {key, until} <- :ets.foldl(fold, [], @ets) do + :ets.update_element(@ets, key, {4, until}) + end + :ets.to_dets(@ets, state.dets) + :dets.close(state.dets) + end + :ok + end + + # Remove an entry from ETS, demonitor related PIDs, and remove from map. + defp remove(key, state) do + :ets.delete(@ets, key) + pids = Enum.filter(state.map, fn({p, {mon,k}}) -> k == key end) |> Enum.map(fn({p, {mon,_}}) -> {p,mon} end) + for {_,mon} <- pids, do: Process.demonitor(mon, [:flush]) + map = Enum.reduce(pids, state.map, fn({p,_}, map) -> Map.delete(map, p) end) + %{state | map: map} + end + + defp verify_validity(_, true) do + true + end + + defp verify_validity(_period, until) do + DateTime.utc_now() >= until + end + +end diff --git a/lib/irc/user.ex b/lib/irc/user.ex new file mode 100644 index 0000000..5e2f526 --- /dev/null +++ b/lib/irc/user.ex @@ -0,0 +1,46 @@ +defmodule Irc.User do + + @moduledoc """ + Represents an IRC user. + + Not all field may be returned in events. + """ + + defstruct [ + :nick, + :user, + :host, + :name, + :account, + :server, + :idle, + :away, + :connected_at, + {:operator, false}, + {:modes, []}, + {:chanmodes, %{}}, + {:channels, []}, + ] + @type t :: %__MODULE__{ + nick: String.t(), + user: nil | String.t(), + host: nil | String.t(), + name: nil | String.t(), + account: nil | String.t(), + server: nil | String.t(), + idle: nil | {non_neg_integer(), NaiveDateTime.t()}, + operator: boolean(), + modes: [], + channels: [], + chanmodes: %{String.t() => String.t()} + } + + def from_mask(%Irc.Mask{user: user, nick: nick, host: host}) when is_binary(nick) do + %__MODULE__{nick: nick, user: user, host: host} + end + + def to_mask(%__MODULE__{nick: nick, user: user, host: host}) do + Irc.Mask.new(nick, user, host) + end + +end diff --git a/priv/numerics.txt b/priv/numerics.txt index 2efb9e4..1a0522d 100644 --- a/priv/numerics.txt +++ b/priv/numerics.txt @@ -28,6 +28,7 @@ 312 RPL_WHOISSERVER 313 RPL_WHOISOPERATOR 314 RPL_WHOWASUSER +315 RPL_ENDOFWHO 317 RPL_WHOISIDLE 318 RPL_ENDOFWHOIS 319 RPL_WHOISCHANNELS @@ -45,6 +46,7 @@ 348 RPL_EXCEPTLIST 349 RPL_ENDOFEXCEPTLIST 351 RPL_VERSION +352 RPL_WHOREPLY 353 RPL_NAMREPLY 366 RPL_ENDOFNAMES 367 RPL_BANLIST diff --git a/test/mask_test.exs b/test/mask_test.exs new file mode 100644 index 0000000..9a55e01 --- /dev/null +++ b/test/mask_test.exs @@ -0,0 +1,22 @@ +defmodule Irc.MaskTest do + use ExUnit.Case + alias Irc.Mask + doctest Irc.Mask + + test "user mask" do + assert Mask.parse("nick!user@host") == {:ok, + %Mask{nick: "nick", user: "user", host: "host", server: nil}} + end + + test "server mask" do + assert Mask.parse("masked.irc.server") == {:ok, + %Mask{server: "masked.irc.server", nick: nil, user: nil, host: nil}} + end + + test "invalid masks" do + assert Mask.parse("bonjour") == {:error, :invalid_mask} + assert Mask.parse("bang!bang") == {:error, :invalid_mask} + assert Mask.parse("ding@dong") == {:error, :invalid_mask} + end + +end diff --git a/test/parser/isupport_test.exs b/test/parser/isupport_test.exs new file mode 100644 index 0000000..e8185b7 --- /dev/null +++ b/test/parser/isupport_test.exs @@ -0,0 +1,36 @@ +defmodule Irc.Parser.IsupportTest do + use ExUnit.Case + alias Irc.Parser.Isupport + doctest Irc.Parser.Isupport + + test "flag" do + assert Isupport.parse(["WHOX"]) == %{"WHOX" => true} + end + + test "k-v" do + assert Isupport.parse(["HELLO=WORLD"]) == %{"HELLO" => "WORLD"} + end + + test "k-v, integer value" do + assert Isupport.parse(["RESPONSE=42"]) == %{"RESPONSE" => 42} + end + + test "key with multiple values" do + assert Isupport.parse(["TARGMAX=NAMES:1,MONITOR:,NOTICE:4"]) == %{ + "TARGMAX" => %{ + "NAMES" => 1, + "MONITOR" => "", + "NOTICE" => 4 + } + } + end + + test "PREFIX" do + assert Isupport.parse(["PREFIX=(ovh)@+%"]) == %{ + "PREFIX" => %{ + "%" => "h", "+" => "v", "@" => "o" + } + } + end + +end diff --git a/test/parser/line_parser.exs b/test/parser/line_parser.exs deleted file mode 100644 index efa0194..0000000 --- a/test/parser/line_parser.exs +++ /dev/null @@ -1,45 +0,0 @@ -defmodule Irc.Parser.LineTest do - use ExUnit.Case - alias Irc.Parser.Line - doctest Irc.Parser.Line - - test "isupport" do - line = ':nowhere 005 hrhrhr TOPICLEN=390 TARGMAX=NAMES:1,MONITOR: EXTBAN=$,acjorsxz CLIENTVER=3.0 :are supported by this server' - |> Line.parse() - assert line.source == "nowhere" - assert line.command == "005" - assert line.args == ["hrhrhr", "TOPICLEN=390", "TARGMAX=NAMES:1,MONITOR:", "EXTBAN=$,acjorsxz", "CLIENTVER=3.0", "are supported by this server"] - end - - test "auth notice" do - line = ':zoidberg.chatspike.net NOTICE Auth :Welcome to ChatSpike!' - |> Line.parse() - assert line.source == "zoidberg.chatspike.net" - assert line.command =="NOTICE" - assert line.args == ["Auth", "Welcome to ChatSpike!"] - end - - test "connect notice" do - line = ':livingstone.freenode.net NOTICE * :*** Checking Ident' - |> Line.parse() - assert line.command == "NOTICE" - assert line.source == "livingstone.freenode.net" - assert line.args == ["*", "*** Checking Ident"] - end - - test "numeric" do - line = ':stitch.chatspike.net 042 derp ABCDEFGHI :your unique ID' - |> Line.parse - assert line.source == "stitch.chatspike.net" - assert line.command == "042" - assert line.args == ["derp", "ABCDEFGHI", "your unique ID"] - end - - test "ping" do - line = 'PING :bender.chatspike.net' - |> Line.parse - assert line.command == "PING" - assert line.args == ["bender.chatspike.net"] - end - -end diff --git a/test/parser/line_test.exs b/test/parser/line_test.exs new file mode 100644 index 0000000..242e5b0 --- /dev/null +++ b/test/parser/line_test.exs @@ -0,0 +1,87 @@ +defmodule Irc.Parser.LineTest do + use ExUnit.Case + alias Irc.Parser.Line + alias Irc.Mask + doctest Irc.Parser.Line + + test "isupport" do + str = ':nowhere 005 hrhrhr TOPICLEN=390 TARGMAX=NAMES:1,MONITOR: EXTBAN=$,acjorsxz CLIENTVER=3.0 :are supported by this server' + line = Line.parse(str) + assert line.source == "nowhere" + assert line.command == "005" + assert line.args == ["hrhrhr", "TOPICLEN=390", "TARGMAX=NAMES:1,MONITOR:", "EXTBAN=$,acjorsxz", "CLIENTVER=3.0", "are supported by this server"] + assert Line.encode(line) == to_string(str) + end + + test "auth notice" do + str = ':zoidberg.chatspike.net NOTICE Auth :Welcome to ChatSpike!' + line = Line.parse(str) + assert line.source == %Mask{server: "zoidberg.chatspike.net"} + assert line.command =="NOTICE" + assert line.args == ["Auth", "Welcome to ChatSpike!"] + assert Line.encode(line) == to_string(str) + end + + test "connect notice" do + str = ':livingstone.freenode.net NOTICE * :*** Checking Ident' + line = Line.parse(str) + assert line.command == "NOTICE" + assert line.source == %Mask{server: "livingstone.freenode.net"} + assert line.args == ["*", "*** Checking Ident"] + assert Line.encode(line) == to_string(str) + end + + test "numeric" do + str = ':stitch.chatspike.net 042 derp ABCDEFGHI :your unique ID' + line = Line.parse(str) + assert line.source == %Mask{server: "stitch.chatspike.net"} + assert line.command == "042" + assert line.args == ["derp", "ABCDEFGHI", "your unique ID"] + assert Line.encode(line) == to_string(str) + end + + test "ping" do + str = 'PING :bender.chatspike.net' + line = Line.parse(str) + assert line.command == "PING" + assert line.args == ["bender.chatspike.net"] + assert Line.encode(line) == to_string(str) + end + + test "tags" do + str = '@lol=dongs :href PRIVMSG #dongs :dongs dongs dongs' + line = Line.parse(str) + assert line.tags == %{"lol" => "dongs"} + refute Line.encode(line) == to_string(str) + assert Line.encode(line, %Irc.Connection{capabs: ["message-tags"]}) == to_string(str) + end + + test "self?" do + line = Line.parse(':href!href@sade.random.sh PRIVMSG #dongs') + line2 = Line.parse(':blep PRIVMSG #blep') + mask = %Irc.Mask{nick: "href", user: "href", host: "sade.random.sh"} + user = Irc.User.from_mask(mask) + conn = %Irc.Connection{nick: "href"} + assert Line.self?(line, mask) + assert Line.self?(line, user) + assert Line.self?(line, conn) + refute Line.self?(line2, mask) + refute Line.self?(line2, user) + refute Line.self?(line2, conn) + end + + test "to?" do + line = Line.parse(':sade.irc.random.sh 332 href #dongs :lol') + line2 = Line.parse(':sade.random.sh 332 blep') + mask = %Irc.Mask{nick: "href", user: "href", host: "sade.random.sh"} + user = Irc.User.from_mask(mask) + conn = %Irc.Connection{nick: "href"} + assert Line.to?(line, mask) + assert Line.to?(line, user) + assert Line.to?(line, conn) + refute Line.to?(line2, mask) + refute Line.to?(line2, user) + refute Line.to?(line2, conn) + end + +end diff --git a/test/parser/prefix_test.exs b/test/parser/prefix_test.exs new file mode 100644 index 0000000..bea651f --- /dev/null +++ b/test/parser/prefix_test.exs @@ -0,0 +1,39 @@ +defmodule Irc.Parser.PrefixTest do + use ExUnit.Case + alias Irc.Parser.Prefix + doctest Irc.Parser.Prefix + + + @conn %Irc.Connection{isupport: %{"PREFIX" => %{"@" => "o", "+" => "v"}}} + + test "who flags (lax)" do + assert Prefix.parse("H*@", @conn, true) == {["o"], "H*"} + end + + test "lax, multiple" do + assert Prefix.parse("H*@+", @conn, true) == {["o", "v"], "H*"} + assert Prefix.parse("H*@+", @conn) == {[], "H*@+"} + end + + test "not lax" do + assert Prefix.parse("@href", @conn) == {["o"], "href"} + end + + test "not lax, multiple" do + assert Prefix.parse("@+href", @conn) == {["o", "v"], "href"} + end + + test "not lax, prefix char in rest" do + assert Prefix.parse("@hr@f", @conn) == {["o"], "hr@f"} + end + + test "no prefix, not lax" do + assert Prefix.parse("href", @conn) == {[], "href"} + end + + test "no prefix, lax" do + assert Prefix.parse("href", @conn, true) == {[], "href"} + end + + +end -- cgit v1.2.3