summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhref <href@random.sh>2020-01-14 16:03:05 +0100
committerhref <href@random.sh>2020-01-14 16:03:05 +0100
commit40a234c6e59d2ea9ef309850b8f38ad30e55017e (patch)
tree0c89d100fe7686935069f3d430f4723a6142f867
parentInitial commit... (diff)
-rw-r--r--README.md16
-rw-r--r--lib/irc/application.ex8
-rw-r--r--lib/irc/base_client.ex170
-rw-r--r--lib/irc/base_client/capabs.ex0
-rw-r--r--lib/irc/client.ex265
-rw-r--r--lib/irc/client/command.ex50
-rw-r--r--lib/irc/client/command/account.ex18
-rw-r--r--lib/irc/client/command/away.ex25
-rw-r--r--lib/irc/client/command/chghost.ex15
-rw-r--r--lib/irc/client/command/invite.ex21
-rw-r--r--lib/irc/client/command/join.ex40
-rw-r--r--lib/irc/client/command/names.ex22
-rw-r--r--lib/irc/client/command/who.ex62
-rw-r--r--lib/irc/client/user_cache.ex31
-rw-r--r--lib/irc/connection.ex347
-rw-r--r--lib/irc/mask.ex52
-rw-r--r--lib/irc/parser.ex6
-rw-r--r--lib/irc/parser/isupport.ex32
-rw-r--r--lib/irc/parser/line.ex161
-rw-r--r--lib/irc/parser/numeric.ex5
-rw-r--r--lib/irc/parser/prefix.ex47
-rw-r--r--lib/irc/sts.ex169
-rw-r--r--lib/irc/user.ex46
-rw-r--r--priv/numerics.txt2
-rw-r--r--test/mask_test.exs22
-rw-r--r--test/parser/isupport_test.exs36
-rw-r--r--test/parser/line_parser.exs45
-rw-r--r--test/parser/line_test.exs87
-rw-r--r--test/parser/prefix_test.exs39
29 files changed, 1449 insertions, 390 deletions
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
--- a/lib/irc/base_client/capabs.ex
+++ /dev/null
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