summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhref <href@random.sh>2019-12-30 11:30:01 +0100
committerhref <href@random.sh>2019-12-30 11:30:01 +0100
commitbc506bcd62cfb1bc62a81600c214fd849b54248d (patch)
tree8c7a2f474c38389591610c4ad294664ebdfe5bdd
Initial commit...
-rw-r--r--.formatter.exs4
-rw-r--r--.gitignore24
-rw-r--r--README.md30
-rw-r--r--config/config.exs30
-rw-r--r--lib/irc.ex18
-rw-r--r--lib/irc/application.ex20
-rw-r--r--lib/irc/base_client.ex170
-rw-r--r--lib/irc/base_client/capabs.ex0
-rw-r--r--lib/irc/client.ex48
-rw-r--r--lib/irc/connection.ex928
-rw-r--r--lib/irc/parser.ex14
-rw-r--r--lib/irc/parser/isupport.ex41
-rw-r--r--lib/irc/parser/line.ex143
-rw-r--r--lib/irc/parser/mode.ex32
-rw-r--r--lib/irc/parser/numeric.ex72
-rw-r--r--lib/irc/shout.ex56
-rw-r--r--mix.exs36
-rw-r--r--mix.lock8
-rw-r--r--priv/numerics.txt101
-rw-r--r--test/irc_test.exs8
-rw-r--r--test/parser/line_parser.exs45
-rw-r--r--test/test_helper.exs1
22 files changed, 1829 insertions, 0 deletions
diff --git a/.formatter.exs b/.formatter.exs
new file mode 100644
index 0000000..d2cda26
--- /dev/null
+++ b/.formatter.exs
@@ -0,0 +1,4 @@
+# Used by "mix format"
+[
+ inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
+]
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1f4ef80
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,24 @@
+# The directory Mix will write compiled artifacts to.
+/_build/
+
+# If you run "mix test --cover", coverage assets end up here.
+/cover/
+
+# The directory Mix downloads your dependencies sources to.
+/deps/
+
+# Where third-party dependencies like ExDoc output generated docs.
+/doc/
+
+# Ignore .fetch files in case you like to edit your project deps locally.
+/.fetch
+
+# If the VM crashes, it generates a dump, let's ignore it too.
+erl_crash.dump
+
+# Also ignore archive artifacts (built via "mix archive.build").
+*.ez
+
+# Ignore package tarball (built via "mix hex.build").
+irc-*.tar
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..8e99077
--- /dev/null
+++ b/README.md
@@ -0,0 +1,30 @@
+# Irc
+
+An 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).
+
+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, ….
+
+## Installation
+
+If [available in Hex](https://hex.pm/docs/publish), the package can be installed
+by adding `irc` to your list of dependencies in `mix.exs`:
+
+```elixir
+def deps do
+ [
+ {:irc, "~> 0.1.0"}
+ ]
+end
+```
+
+Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
+and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
+be found at [https://hexdocs.pm/irc](https://hexdocs.pm/irc).
+
diff --git a/config/config.exs b/config/config.exs
new file mode 100644
index 0000000..36bd1d8
--- /dev/null
+++ b/config/config.exs
@@ -0,0 +1,30 @@
+# This file is responsible for configuring your application
+# and its dependencies with the aid of the Mix.Config module.
+use Mix.Config
+
+# This configuration is loaded before any dependency and is restricted
+# to this project. If another project depends on this project, this
+# file won't be loaded nor affect the parent project. For this reason,
+# if you want to provide default values for your application for
+# third-party users, it should be done in your "mix.exs" file.
+
+# You can configure your application as:
+#
+# config :irc, key: :value
+#
+# and access this configuration in your application as:
+#
+# Application.get_env(:irc, :key)
+#
+# You can also configure a third-party app:
+#
+# config :logger, level: :info
+#
+
+# It is also possible to import configuration files, relative to this
+# directory. For example, you can emulate configuration per environment
+# by uncommenting the line below and defining dev.exs, test.exs and such.
+# Configuration from the imported file will override the ones defined
+# here (which is why it is important to import them last).
+#
+# import_config "#{Mix.env()}.exs"
diff --git a/lib/irc.ex b/lib/irc.ex
new file mode 100644
index 0000000..e322585
--- /dev/null
+++ b/lib/irc.ex
@@ -0,0 +1,18 @@
+defmodule Irc do
+ @moduledoc """
+ Documentation for Irc.
+ """
+
+ @doc """
+ Hello world.
+
+ ## Examples
+
+ iex> Irc.hello()
+ :world
+
+ """
+ def hello do
+ :world
+ end
+end
diff --git a/lib/irc/application.ex b/lib/irc/application.ex
new file mode 100644
index 0000000..82fc053
--- /dev/null
+++ b/lib/irc/application.ex
@@ -0,0 +1,20 @@
+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}
+ ]
+
+ # 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
+end
diff --git a/lib/irc/base_client.ex b/lib/irc/base_client.ex
new file mode 100644
index 0000000..8da45be
--- /dev/null
+++ b/lib/irc/base_client.ex
@@ -0,0 +1,170 @@
+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
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/irc/base_client/capabs.ex
diff --git a/lib/irc/client.ex b/lib/irc/client.ex
new file mode 100644
index 0000000..1c4b0c5
--- /dev/null
+++ b/lib/irc/client.ex
@@ -0,0 +1,48 @@
+defmodule Irc.Client do
+ alias Irc.BaseClient
+ @behaviour Irc.BaseClient
+ require Logger
+
+ @moduledoc """
+ Fully-featured IRC client.
+
+ The client uses message passing.
+
+ """
+
+ @type message :: Irc.BaseClient.event
+ @type start_ret :: BaseClient.start_ret
+
+ @spec start(Irc.Connection.nick, Irc.Connection.host, [BaseClient.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
+ 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}}
+ end
+
+ @doc false
+ def handle_event(event, state) do
+ Logger.debug("CLIENT EVENT: #{inspect event}")
+ :ok
+ end
+
+ @doc false
+ def stop(reason, state) do
+ Logger.debug("CLIENT: Stopping, #{inspect reason}")
+ end
+
+ defp prepare_args(opts) do
+ opts
+ |> Keyword.put_new(:module, __MODULE__)
+ end
+end
+
diff --git a/lib/irc/connection.ex b/lib/irc/connection.ex
new file mode 100644
index 0000000..f43fca7
--- /dev/null
+++ b/lib/irc/connection.ex
@@ -0,0 +1,928 @@
+defmodule Irc.Connection do
+ @behaviour :gen_statem
+ require Logger
+ alias Irc.Parser.Line
+ require Irc.Parser.Numeric
+ import Irc.Parser.Numeric
+
+ @backoff_min :timer.seconds(5)
+ @backoff_max :timer.minutes(15)
+ @connect_timeout :timer.minutes(1)
+ @reply_timeout :timer.seconds(1)
+ @cap_timeout :timer.seconds(15)
+ @registration_timeout :timer.seconds(30)
+ @support_capabs ["sasl", "cap-notify"]
+ @internal {:next_event, :internal, nil}
+
+ defstruct [:server, :nick, :modes, :capabs, :server_version, :server_umodes, :server_cmodes, :server_cmodes_with_params, :isupport, :motd]
+
+ @typedoc "Connection Information state"
+ @type t :: %__MODULE__{
+ server: String.t,
+ nick: String.t,
+ capabs: list(),
+ modes: String.t,
+ server_version: String.t,
+ server_umodes: String.t,
+ server_cmodes: String.t,
+ server_cmodes_with_params: String.t,
+ isupport: isupport(),
+ motd: [String.t],
+ }
+
+ @typedoc "Server ISUPPORT"
+ @type isupport :: %{String.t() => true | nil | String.t | %{String.t() => any()}}
+
+ @typedoc "Messages sent to the owning process."
+ @type message :: msg_up() | msg_down() | msg_error()
+ | msg_line() | msg_umodes() | msg_nick()
+
+ @typedoc "Connection is now up and registered."
+ @type msg_up :: {:irc_conn_up, pid(), t()}
+ @typedoc "Message: Received an IRC line."
+ @type msg_line :: {:irc_conn_line, pid(), Line.t}
+ @typedoc "Message: Connection user modes have changed."
+ @type msg_umodes :: {:irc_conn_modes, pid(), changes :: String.t, previous_modes :: list(), modes :: list()}
+ @typedoc "Message: Connection nickname have changed."
+ @type msg_nick :: {:irc_conn_nick, pid(), previous_nick :: String.t, new_nick :: String.t}
+ @typedoc "Message: Connection is down and will try to reconnect in `delay`."
+ @type msg_down :: {:irc_conn_down, pid(), error, delay :: integer}
+ @typedoc "Message: Connection encountered a fatal error and stopped."
+ @type msg_error :: {:irc_conn_error, pid(), error}
+
+ @typedoc "Connection error reasons"
+ @type error :: {:error, error_irc | any}
+
+ @typedoc "IRC Error reasons"
+ @type error_irc :: {:killed, by :: String.t, reason :: String.t}
+ | {:quit, reason :: String.t}
+ | {:irc_error, reason :: String.t}
+
+ @typedoc """
+ Start options.
+
+ * user: IRC user,
+ * name: IRC real name,
+ * pass: IRC server password,
+ * capabs: IRCv3 capabs to request (if available),
+ * port,
+ * tls,
+ * reconnect,
+ * process,
+ * tcp_opts and tls_opts.
+ """
+ @type start_opt :: {:user, nil | String.t}
+ | {:name, nil | String.t}
+ | {:pass, nil | String.t}
+ | {:capabs, [String.t]}
+ | {:port, 6667 | Integer.t}
+ | {:tls, false | true}
+ | {:inet, nil | 4 | 6}
+ | {:iface, nil | String.t}
+ | {:ip, nil | String.t}
+ | {:reconnect, true | false}
+ | {:process, pid}
+ | {:tcp_opts, [:gen_tcp.connect_option]}
+ | {:tls_opts, [:ssl.connect_option]}
+
+ @moduledoc """
+ Lightweight implementation of an IRC Connection handling only the basics:
+
+ * the socket and reconnections,
+ * registration process,
+ * sasl authentication,
+ * capabilities negotiation,
+ * ping,
+ * nick and user mode changes.
+
+ Events and lines are sent to calling PID.
+
+ ## Usage
+
+ The client is started using `start/4` or `start_link/4`:
+
+ ```elixir
+ {:ok, Pid} = Irc.Connection.start("ExIrcTest", "irc.random.sh", [tls: true, port: 6697], [])
+ ```
+
+ Messages will be sent to your PID, see `t:message/0` for the possible types.
+
+ You can send lines using `sendline/3`, and disconnect with `disconnect/3`.
+
+ Status and information about the connection (`t:t/0`) can be requested with `info/1`.
+
+ If you are looking for a more featured client, you probably want `Irc.Client`!
+ """
+
+ @type nick :: String.t
+ @type host :: String.t
+
+ @type start_ret :: {:ok, pid} | {:error, error} | :gen_statem.start_ret
+
+ @spec start(nick, host, [start_opt], [:gen_statem.start_opt]) :: start_ret
+ @doc """
+ Start a connection.
+ """
+ def start(nick, host, opts \\ [], start_opts \\ []) do
+ :gen_statem.start(__MODULE__, [prepare_args(nick, host, opts)], start_opts)
+ end
+
+ @spec start_link(nick, host, [start_opt], [:gen_statem.start_opt]) :: start_ret
+ @doc """
+ Start and link a connection.
+ """
+ def start_link(nick, host, opts \\ [], start_opts \\ []) do
+ :gen_statem.start_link(__MODULE__, [prepare_args(nick, host, opts)], start_opts)
+ end
+
+
+ @spec reconnect(pid) :: :ok | {:error, :not_disconnected}
+ @doc """
+ Reconnects the connection.
+
+ Can only be used when the connection is disconnected. This will override either a `disconnect/3`, or re-connect early after an error
+ """
+ def reconnect(pid) do
+ :gen_statem.call(pid, :reconnect)
+ end
+
+ @spec disconnect(pid, quit_message :: String.t, stop :: boolean) :: :ok | {:error, :not_connected} | {:error, :noproc}
+ @doc """
+ Disconnects.
+
+ If `stop` is set to false (default true), the connection PID will keep running and can be reconnected with `reconnect/1`
+ """
+ def disconnect(pid, quit_message \\ nil, stop \\ true) do
+ :gen_statem.call(pid, {:disconnect, stop, quit_message})
+ catch
+ :exit, {:noproc, _} -> {:error, :noproc}
+ end
+
+ @spec info(pid) :: {:connected, t()} | :connecting | :disconnected
+ @doc "Returns the connection state and info."
+ def info(pid) do
+ :gen_statem.call(pid, :info)
+ end
+
+ @spec sendline(pid, iolist, sync :: boolean) :: :ok | {:error, :not_connected} | msg_error
+ @doc "Sends a line over the connection."
+ def sendline(pid, data, sync \\ true) do
+ fun = if sync, do: :call, else: :cast
+ apply(:gen_statem, fun, [pid, {:send, data}])
+ end
+
+ @doc "Wait until the connection is registered."
+ @spec await_up(pid, non_neg_integer | :infinity) :: {:ok, t} | msg_error | {:error, :await_up_timeout}
+ def await_up(pid, timeout \\ :infinity) do
+ mon = Process.monitor(pid)
+ result = receive do
+ {:irc_conn_up, pid, info} -> {:ok, info}
+ {:irc_conn_error, pid, error} -> {:error, error}
+ {:DOWN, _, :process, pid, _} -> {:error, :down}
+ after
+ timeout -> {:error, :await_up_timeout}
+ end
+ flush(pid)
+ Process.demonitor(mon)
+ result
+ end
+
+ @doc "Flush all messages from the connection."
+ def flush(pid) do
+ receive do
+ {:irc_conn_up, pid, _} -> flush(pid)
+ {:irc_conn_line, pid, _} -> flush(pid)
+ {:irc_conn_modes, pid, _, _, _} -> flush(pid)
+ {:irc_conn_nick, pid, _, _} -> flush(pid)
+ {:irc_conn_down, pid, _, _} -> flush(pid)
+ {:irc_conn_error, pid, _} -> flush(pid)
+ after
+ 0 -> :ok
+ end
+ end
+
+ defmodule State do
+ @moduledoc false
+ defstruct [:args,
+ :backoff, :socket, :sockmodule, {:tries, 0},
+ :process, :monitor,
+ :nick,
+ :server,
+ :server_version,
+ :server_umodes,
+ :server_cmodes,
+ :server_cmodes_with_params,
+ :welcomed,
+ {:errored, false},
+ {:isupport, %{}},
+ {:modes, []},
+ {:server_capabs, []},
+ {:req_capabs, []},
+ {:capabs, []},
+ {:buffer, []},
+ {:motd, []},
+ {:welcome, []},
+ ]
+ end
+
+ @doc false
+ def callback_mode, do: [:state_functions, :state_enter]
+
+ @doc false
+ def init([args]) do
+ backoff_min = Keyword.get(args, :backoff_min, @backoff_min)
+ backoff_max = Keyword.get(args, :backoff_max, @backoff_max)
+ process = Keyword.get(args, :process, nil)
+ monitor = Process.monitor(process)
+ with \
+ {:ok, processed_args} <- process_args(args),
+ backoff <- :backoff.init(backoff_min, backoff_max)
+ do
+ data = %State{
+ args: processed_args,
+ process: process,
+ monitor: monitor,
+ backoff: backoff
+ }
+ {:ok, :connect, data, @internal}
+ else
+ {:error, error} ->
+ {:stop, error}
+ end
+ end
+
+ # TCP Open
+
+ @doc false
+ def connect(:internal, _, data = %{args: %{tls: true}}) do
+ {:next_state, :connect_tls, data, {:next_event, :internal, nil}}
+ end
+
+ def connect(:internal, _, data) do
+ Logger.debug("#{inspect(data)} connecting...")
+ options = data.args.bind ++ data.args.inet ++ [{:active, true}, {:packet, :line}] ++ data.args.tcp_opts
+ case :gen_tcp.connect(String.to_charlist(data.args.host), data.args.port, options) do
+ {:ok, socket} ->
+ {_, backoff} = :backoff.succeed(data.backoff)
+ {:next_state, :connected, %State{data | backoff: backoff}, {:next_event, :internal, {:gen_tcp, socket}}}
+ error ->
+ # TODO ERRORS
+ to_disconnected(error, data)
+ end
+ end
+
+ def connect(type, content, data) do
+ handle_common(:connect, type, content, data)
+ end
+
+ # TLS Connect
+
+ @doc false
+ def connect_tls(:internal, nil, data) do
+ options = data.args.bind ++ data.args.inet ++ [{:active, true}, {:packet, :line}] ++ data.args.tcp_opts ++ data.args.tls_opts
+ case :ssl.connect(String.to_charlist(data.args.host), data.args.port, options) do
+ {:ok, socket} ->
+ {_, backoff} = :backoff.succeed(data.backoff)
+ {:next_state, :connected, %State{data | backoff: backoff}, {:next_event, :internal, {:ssl, socket}}}
+ error ->
+ to_disconnected(error, data)
+ end
+ end
+
+ def connect_tls(type, content, data) do
+ handle_common(:connect_tls, type, content, data)
+ end
+
+ @doc false
+ def connected(:internal, {module, socket}, data) do
+ {:next_state, :cap_ls, %State{data | sockmodule: module, socket: socket}, @internal}
+ end
+
+ def connected(type, content, data) do
+ handle_common(:connected, type, content, data)
+ end
+
+ # Registration: LIST CAPABS
+
+ @doc false
+ def cap_ls(:internal, _, data) do
+ case socksend(data, 'CAP LS 320') do
+ :ok -> {:keep_state_and_data, {:state_timeout, @cap_timeout, nil}}
+ error ->
+ Logger.error("Disconnected in socksend")
+ to_disconnected(error, data)
+ end
+ end
+
+ def cap_ls(:info, msg = {module, socket, line}, data) do
+ case Line.parse(line) do
+ l = %Line{command: "CAP", args: [_, "LS", "*", rest]} ->
+ capabs = String.split(rest, " ")
+ {:keep_state, %State{data | server_capabs: data.capabs ++ capabs}, {:state_timeout, @reply_timeout, nil}}
+ l = %Line{command: "CAP", args: ["*", "LS", rest]} ->
+ capabs = Enum.uniq(data.capabs ++ String.split(rest, " "))
+ {:next_state, :reg_pass, %State{data | server_capabs: capabs}, @internal}
+ # TODO: What do?
+ l ->
+ handle_common(:cap_ls, :info, msg, data)
+ # {:next_state, :registration, data, {:next_event, :internal, nil}}
+ end
+ end
+
+ def cap_ls(:state_timeout, _, data) do
+ capabs = Enum.uniq(data.capabs)
+ Logger.debug("State timeout")
+ {:next_state, :reg_pass, %State{data | server_capabs: capabs}, @internal}
+ end
+
+ def cap_ls(type, content, data) do
+ Logger.debug("Handling common #{inspect({type,content,data})}")
+ handle_common(:cap_ls, type, content, data)
+ end
+
+ # Registration: PASS
+
+ @doc false
+ def reg_pass(:internal, _, data = %State{args: %{pass: pass}}) when is_binary(pass) and pass != "" do
+ case socksend(data, ['PASS ', pass]) do
+ :ok ->
+ {:keep_state_and_data, {:state_timeout, @reply_timeout, nil}}
+ error ->
+ to_disconnected(error, data)
+ end
+ end
+
+ def reg_pass(:internal, _, data) do
+ {:next_state, :reg_nick, data, @internal}
+ end
+
+ def reg_pass(:info, content = {_, _, line}, data) do
+ case Line.parse(line) do
+ %Line{command: err_PASSWDMISMATCH()} ->
+ {:next_state, :error, {:invalid_password, data.args.pass}}
+ line ->
+ #{:keep_state_and_data, [:postpone, {:state_timeout, @reply_timeout, nil}]}
+ handle_common(:reg_pass, :info, content, data)
+ end
+ end
+
+ def reg_pass(:state_timeout, _, data) do
+ {:next_state, :reg_nick, data, @internal}
+ end
+
+ def reg_pass(type, content, data) do
+ handle_common(:reg_pass, type, content, data)
+ end
+
+ # --- Registration: NICKNAME
+
+ @doc false
+ def reg_nick(:internal, nick, data) do
+ case socksend(data, ['NICK ', nick || data.args.nick]) do
+ :ok -> {:keep_state, %State{data | nick: nick}, {:state_timeout, @reply_timeout, nil}}
+ error -> to_disconnected(error, data)
+ end
+ end
+
+ def reg_nick(:info, content = {_, _, line}, data) do
+ case Line.parse(line) do
+ %Line{command: cmd} when cmd in [err_NICKNAMEINUSE(), err_NICKCOLLISION()] ->
+ new_nick = data.args.nick <> to_string(:random.uniform(9999))
+ {:keep_state, data, {:next_event, :internal, new_nick}}
+ line = %Line{command: cmd} when cmd in [err_ERRONEUSNICKNAME(), err_NONICKNAMEGIVEN()] ->
+ {:next_state, :error, data, {:next_event, :internal, {:invalid_nickname, data.args.nick, line}}}
+ line ->
+ #{:keep_state_and_data, :postpone}
+ handle_common(:reg_nick, :info, content, data)
+ end
+ end
+
+ def reg_nick(:state_timeout, _, data) do
+ {:next_state, :reg_user, data, @internal}
+ end
+
+ def reg_nick(type, content, data) do
+ handle_common(:reg_nick, type, content, data)
+ end
+
+ # --- Registration: USER
+
+ @doc false
+ def reg_user(:internal, _, data) do
+ user = List.flatten(['USER ', data.args.user, ' 0 * :', data.args.name])
+ case socksend(data, user) do
+ :ok ->
+ {:next_state, :cap_req, data, @internal}
+ error ->
+ to_disconnected(error, data)
+ end
+ end
+
+ #def reg_user(:state_timeout, _, data) do
+ # {:next_state, :welcome, data, {:state_timeout, @registration_timeout, nil}}
+ #end
+
+ def reg_user(type, content, data) do
+ handle_common(:reg_user, type, content, data)
+ end
+
+ @doc false
+ def cap_req(:internal, _, data = %{server_capabs: []}) do
+ {:next_state, :welcome, data}
+ end
+
+ def cap_req(:internal, _, data) do
+ capabs = data.args.capabs ++ @support_capabs
+ req = Enum.filter(@support_capabs, fn(capab) -> Enum.member?(data.server_capabs, capab) end)
+ |> Enum.uniq()
+ |> Enum.join(" ")
+ case socksend(data, ['CAP REQ :', String.to_charlist(req)]) do
+ :ok -> {:next_state, :cap_wait_ack, data}
+ error -> to_disconnected(error, data)
+ end
+ end
+
+ def cap_req(type, content, data) do
+ handle_common(:cap_req, type, content, data)
+ end
+
+ @doc false
+ def cap_wait_ack(:enter, _, _) do
+ {:keep_state_and_data, {:state_timeout, @reply_timeout, nil}}
+ end
+
+ # FIXME: Support multiline ACK/NACK
+ def cap_wait_ack(:info, content = {_, _, line}, data) do
+ case Line.parse(line) do
+ %Line{command: "CAP", args: [_, "ACK", capabs]} ->
+ capabs = String.split(capabs, " ")
+ {:keep_state, %State{data | capabs: capabs}, {:next_event, :internal, :end}}
+ %Line{command: "CAP", args: [_, "NACK", capabs]} ->
+ capabs = String.split(capabs, " ")
+ {:keep_state, %State{data | capabs: data.capabs -- capabs}, {:next_event, :internal, :end}}
+ line ->
+ handle_common(:cap_wait_ack, :info, content, data)
+ end
+ end
+
+ def cap_wait_ack(:internal, :end, data) do
+ case socksend(data, 'CAP END') do
+ :ok -> {:next_state, :welcome, data}
+ error -> to_disconnected(error, data)
+ end
+ end
+
+ # --- REGISTRATION: Welcome
+
+ @doc false
+ def welcome(:enter, _, data) do
+ {:keep_state_and_data, {:state_timeout, @registration_timeout, nil}}
+ end
+
+ @welcome_numeric [rpl_WELCOME, rpl_YOURHOST, rpl_CREATED, rpl_MYINFO, rpl_ISUPPORT]
+ def welcome(:info, content = {module, socket, line}, data) do
+ case Line.parse(line) do
+ l = %Line{command: err_NOMOTD()} ->
+ {:next_state, :registered, %State{data | motd: []}}
+ l = %Line{command: rpl_MOTDSTART()} ->
+ {:next_state, :motd, data, [:postpone]}
+ l = %Line{command: rpl_WELCOME(), source: source, args: [nick, _]} ->
+ {:keep_state, %State{data | nick: nick, welcome: [l | data.welcome], server: source}, {:state_timeout, @reply_timeout, nil}}
+ l = %Line{command: rpl_MYINFO(), source: source, args: [_, _, version, umodes, cmodes | rest]} ->
+ cmodes_with_params = case rest do
+ [] -> nil
+ [c] -> c
+ end
+ data = %State{data | welcome: [l | data.welcome], server_version: version, server_umodes: umodes,
+ server_cmodes: cmodes, server_cmodes_with_params: cmodes_with_params}
+ {:keep_state, data, {:state_timeout, @reply_timeout, nil}}
+ l = %Line{command: rpl_ISUPPORT(), args: [_ | isupport]} ->
+ supports = Irc.Parser.Isupport.parse(isupport, data.isupport)
+ {:keep_state, %State{data | isupport: supports}, {:state_timeout, @reply_timeout, nil}}
+ l = %Line{command: cmd} ->
+ #{:keep_state, %State{data | welcome: [l | data.welcome]}, {:state_timeout, @reply_timeout, nil}}
+ handle_common(:welcome, :info, content, data)
+ end
+ end
+
+ def welcome(:state_timeout, _, data) do
+ to_disconnected(:registration_timeout, data)
+ end
+
+ def welcome(type, content, data) do
+ handle_common(:welcome, type, content, data)
+ end
+
+ # --- Registration: MOTD
+
+ @doc false
+ def motd(:enter, _, data) do
+ {:keep_state_and_data, {:state_timeout, @reply_timeout, nil}}
+ end
+
+ def motd(:info, {_, _, line}, data) do
+ case Line.parse(line) do
+ %Line{command: rpl_MOTDSTART(), args: [_, line]} ->
+ {:keep_state, %State{data | motd: [line]}, {:state_timeout, @reply_timeout, nil}}
+ %Line{command: err_NOMOTD()} ->
+ {:next_state, :registered, %State{data | motd: []}}
+ %Line{command: rpl_MOTD(), args: [_, line]} ->
+ {:keep_state, %State{data | motd: [line | data.motd]}, {:state_timeout, @reply_timeout, nil}}
+ %Line{command: rpl_ENDOFMOTD(), args: [_, line]} ->
+ {:next_state, :registered, %State{data | motd: [line | data.motd]}}
+ line ->
+ {:keep_state_and_data, :postpone}
+ end
+ end
+
+ def motd(:state_timeout, _, data) do
+ to_disconnected(:motd_timeout, data)
+ end
+
+ def motd(type, content, data) do
+ handle_common(:motd, type, content, data)
+ end
+
+ # --- Registered
+
+ defp to_info(data, state \\ nil) do
+ data
+ |> Map.from_struct()
+ |> Map.take([:nick, :modes, :server, :server_version, :server_umodes, :server_cmodes, :server_cmodes_with_params, :capabs, :isupport, :motd])
+ |> Map.put(:__struct__, __MODULE__)
+ end
+
+ @conn_ping_timeout {:state_timeout, :timer.seconds(30), :ping}
+ @conn_pong_timeout {:state_timeout, :timer.seconds(30), :pong}
+
+ @doc false
+ def registered(:enter, _, data) do
+ data = %State{data | welcome: Enum.reverse(data.welcome),
+ motd: Enum.reverse(data.motd),
+ welcomed: true
+ }
+ msg = {:irc_conn_up, self(), to_info(data, :registered)}
+ send(data.process, msg)
+ Logger.info("#{inspect(data)} connected and registered")
+ {:next_state, :registered, data, @conn_ping_timeout}
+ end
+
+ def registered(:info, {_, _, line}, data) do
+ line = Line.parse(line)
+ Logger.debug "#{inspect(data)} <<< #{inspect(line)}"
+ case process_line(line, data) do
+ :ignore ->
+ msg = {:irc_conn_line, self(), line}
+ send(data.process, msg)
+ :keep_state_and_data
+ return ->
+ return
+ end
+ end
+
+ def registered({:call, ref}, {:send, line}, data) do
+ case socksend(data, line) do
+ :ok -> {:keep_state_and_data, [{:reply, ref, :ok}, @conn_ping_timeout]}
+ error -> {:next_state, :disconnected, data, [{:reply, :ref, {:error, error}}, {:next_event, :internal, error}]}
+ end
+ end
+
+ def registered(:cast, {:send, line}, data) do
+ case socksend(data, line) do
+ :ok -> {:keep_state_and_data, @conn_ping_timeout}
+ error -> to_disconnected(error, data)
+ end
+ end
+
+ def registered(:state_timeout, :ping, data) do
+ case socksend(data, [?P, ?I, ?N, ?G, 0x20, String.to_charlist(data.server)]) do
+ :ok -> {:keep_state_and_data, @conn_pong_timeout}
+ error -> to_disconnected(error, data)
+ end
+ end
+
+ def registered({:call, ref}, :info, data) do
+ {:keep_state_and_data, {:reply, ref, {:connected, to_info(data)}}}
+ end
+
+ def registered(:state_timeout, :pong, data) do
+ to_disconnected({:irc_error, {:timeout, :pang}}, data)
+ end
+
+ def registered(type, content, data) do
+ handle_common(:registered, type, content, data)
+ end
+
+ @doc false
+ def process_line(%Line{command: "KILL", source: source, args: [nick, reason]}, data = %{nick: nick}) do
+ to_disconnected({:killed, source, reason}, data)
+ end
+
+ def process_line(%Line{command: "QUIT", source: source, args: [reason]}, data = %{nick: nick}) do
+ if String.starts_with?(source, nick<>"!") do
+ to_disconnected({:quit, source, reason}, data)
+ else
+ :ignore
+ end
+ end
+
+ def process_line(%Line{command: "ERROR", args: args}, data) do
+ to_disconnected({:irc_error, Enum.join(args, " ")}, data)
+ end
+
+ def process_line(%Line{command: "PONG", args: [server | _]}, data = %{server: server}) do
+ {:keep_state_and_data, @conn_ping_timeout}
+ end
+
+ def process_line(%Line{command: "PING", args: args}, data = %{server: server}) do
+ case socksend(data, [?P, ?O, ?N, ?G, 0x20, ?:, args |> Enum.join(" ")]) do
+ :ok -> {:keep_state_and_data, @conn_ping_timeout}
+ error -> to_disconnected(error, data)
+ end
+ end
+
+ def process_line(%Line{command: "MODE", args: [nick, changes]}, data = %{nick: nick}) do
+ modes = Irc.Parser.Mode.changes(changes, data.modes)
+ send(data.process, {:irc_conn_modes, self(), changes, data.modes, modes})
+ {:keep_state, %State{data | modes: modes}}
+ end
+
+ def process_line(%Line{command: "NICK", args: [new_nick]}, data = %{nick: nick}) do
+ send(data.process, {:irc_conn_nick, self(), nick, new_nick})
+ {:keep_state, %State{data | nick: nick}}
+ end
+
+ def process_line(line, _) do
+ :ignore
+ end
+
+ # Disconnected state
+
+ @doc false
+ def disconnected(:enter, from, data) do
+ Logger.debug "#{inspect data} entering disconnected state"
+ :keep_state_and_data
+ end
+
+ def disconnect(:internal, error, data = %{args: %{reconnect: false}}) do
+ {:next_state, :error, data, {:next_event, :internal, error}}
+ end
+
+ def disconnected(:internal, :disconnected, _) do
+ :keep_state_and_data
+ end
+
+ def disconnected(:internal, error, data) do
+ {delay, backoff} = :backoff.fail(data.backoff)
+ send(data.process, {:irc_conn_down, self(), error, delay})
+ data = if data.socket do
+ data.sockmodule.close(data.socket)
+ %State{data | socket: nil, sockmodule: nil}
+ else
+ data
+ end
+ data = %State{data | welcomed: false, server: nil, nick: nil, welcome: [], motd: [], isupport: %{}, backoff: backoff}
+ Logger.error("#{inspect data} - #{inspect error} - (reconnecting in #{inspect delay})")
+ {:keep_state, data, {:state_timeout, delay, error}}
+ end
+
+ def disconnected({:call, ref}, :reconnect, data) do
+ {:next_state, :connect, data, [@internal, {:reply, ref, :ok}]}
+ end
+
+ def disconnected({:call, ref}, :info, data) do
+ {:keep_state_and_data, {:reply, ref, :disconnected}}
+ end
+
+ def disconnected({:call, ref}, {:disconnect, false, _}, data) do
+ {:keep_state_and_data, [{:state_timeout, :infinity, :disconnect}, {:reply, ref, {:error, :not_connected}}]}
+ end
+
+ def disconnected(:state_timeout, _error, data) do
+ {:next_state, :connect, data, @internal}
+ end
+
+ def disconnected(:info, {_, _, _}, data) do
+ :keep_state_and_data
+ end
+
+ def disconnected(:info, {closed, socket}, data) when closed in [:tcp_closed, :ssl_closed] do
+ :keep_state_and_data
+ end
+
+ def disconnected(type, content, data) do
+ handle_common(:disconnected, type, content, data)
+ end
+
+ # --- Fatal error state
+
+ @doc false
+ def error(:internal, error, data) do
+ send(data.process, {:irc_conn_error, self(), error})
+ {:stop, %State{data | errored: true}, error}
+ end
+
+ # --- Common defaults
+
+ @doc false
+ def handle_common(_, :enter, _, _) do
+ :keep_state_and_data
+ end
+
+ def handle_common(_, {:call, ref}, {:disconnect, stop, quit}, data = %State{socket: socket}) do
+ Logger.debug("#{inspect(data)} disconnecting on request (stop:#{inspect stop})")
+ if data.sockmodule && data.socket do
+ quit = if quit, do: quit, else: ""
+ socksend(data, ['QUIT :', quit])
+ data.sockmodule.close(socket)
+ end
+ if stop do
+ {:stop_and_reply, :normal, {:reply, ref, :ok}}
+ else
+ {:next_state, :disconnected, %State{data | socket: nil, sockmodule: nil}, [
+ {:reply, ref, :ok},
+ {:next_event, :internal, :disconnected},
+ ]}
+ end
+ end
+
+ def handle_common(_, {:call, ref}, {:disconnect, false, _}, data) do
+ {:next_state, :disconnected, data, [{:reply, ref, :ok}, {:next_event, :internal, :disconnected}]}
+ end
+
+ def handle_common(_, {:call, ref}, {:disconnect, true, _}, _) do
+ {:stop_and_reply, :normal, {:reply, ref, :ok}}
+ end
+
+ def handle_common(_, {:call, ref}, :info, data) do
+ {:keep_state_and_data, {:reply, ref, :connecting}}
+ end
+
+ def handle_common(_, {:call, ref}, :reconnect, _) do
+ {:keep_state_and_data, {:reply, ref, {:error, :not_disconnected}}}
+ end
+ def handle_common(_, {:call, ref}, {:send, _}, _) do
+ {:keep_state_and_data, {:reply, ref, {:error, :not_connected}}}
+ end
+
+ def handle_common(_, :cast, {:send, _}, _) do
+ :keep_state_and_data
+ end
+
+ def handle_common(_, :info, {_mod, _sock, line = [?P, ?I, ?N, ?G | _]}, data) do
+ line = Line.parse(line)
+ arg = Enum.join(line.args, " ")
+ case socksend(data, ['PONG :', arg]) do
+ :ok -> :keep_state_and_data
+ error -> to_disconnected(error, data)
+ end
+ end
+
+ def handle_common(_, :info, {_mod, _sock, line = [?E, ?R, ?R, ?O, ?R | _]}, data) do
+ line = Line.parse(line)
+ to_disconnected({:irc_error, Enum.join(line.args, " ")}, data)
+ end
+
+
+ def handle_common(_, :info, {_mod, _sock, _line}, data) do
+ {:keep_state_and_data, :postpone}
+ end
+
+ def handle_common(_, :info, {closed, socket}, data) when closed in [:tcp_closed, :ssl_closed] do
+ {:next_state, :disconnected, %State{data | socket: nil, sockmodule: nil}, @internal}
+ end
+
+ @doc false
+ def terminate(reason, state, data) do
+ unless data.errored do
+ send(data.process, {:irc_conn_error, self(), :down})
+ end
+ Logger.error("#{inspect(data)} terminating in #{inspect(state)}: #{inspect reason}")
+ end
+
+ @doc false
+ def code_change(_old_vsn, old_state, old_data, _extra) do
+ {:ok, old_state, old_data}
+ end
+
+ # Send helpers.
+ defp socksend(data = %State{sockmodule: module, socket: socket}, line) when is_list(line) do
+ Logger.debug("#{inspect data} >>> #{inspect line}")
+ module.send(socket, [line | [?\r, ?\n]])
+ end
+
+
+ # Helper to enter to the disconnected state.
+ defp to_disconnected(error, data) do
+ {:next_state, :disconnected, data, {:next_event, :internal, error}}
+ end
+
+ # -- Arguments processing/validation
+
+ # Prepare args (in start)
+ defp prepare_args(nick, host, args) do
+ args
+ |> Keyword.put_new(:nick, nick)
+ |> Keyword.put_new(:host, host)
+ |> Keyword.put_new(:process, self())
+ end
+
+ # Process args (in init)
+ defp process_args(args) do
+ host = Keyword.get(args, :host, nil)
+ port = Keyword.get(args, :port, nil) || 6667
+ tls = Keyword.get(args, :tls, nil) || false
+ inet = case Keyword.get(args, :inet, nil) do
+ 4 -> [:inet]
+ 6 -> [:inet6]
+ _ -> []
+ end
+ ip = Keyword.get(args, :ip, nil)
+ ifaddr = Keyword.get(args, :ifaddr, nil)
+ connect_timeout = Keyword.get(args, :connect_timeout, @connect_timeout)
+ nick = Keyword.get(args, :nick, nil)
+ user = Keyword.get(args, :user, nick) || nick
+ name = Keyword.get(args, :name, user) || user
+ pass = Keyword.get(args, :pass, nil)
+ capabs = Keyword.get(args, :capabs, []) || []
+ reconnect = Keyword.get(args, :reconnect, true)
+ tcp_opts = Keyword.get(args, :tcp_opts, []) || []
+ tls_opts = Keyword.get(args, :tls_opts, []) || []
+
+ with \
+ {:host, {:ok, host}} <- {:host, parse_host(host)},
+ {:port, {:ok, port}} <- {:port, parse_port(port)},
+ {:bind, {:ok, bind}} <- {:bind, parse_bind(ip, ifaddr)}
+ do
+ {:ok, %{
+ host: host,
+ port: port,
+ bind: bind,
+ tls: tls,
+ capabs: capabs,
+ inet: inet,
+ nick: nick,
+ user: user,
+ name: name,
+ pass: pass,
+ reconnect: reconnect,
+ tcp_opts: tcp_opts,
+ tls_opts: tls_opts,
+ connect_timeout: connect_timeout
+ }}
+ else
+ error -> error
+ end
+ end
+
+ defp parse_host(host) when is_binary(host) do
+ {:ok, host}
+ end
+ defp parse_host(host), do: {:error, {:invalid_format, :host, inspect(host)}}
+
+ defp parse_port(port) when is_integer(port) when port > 0 and port < 65336 do
+ {:ok, port}
+ end
+ defp parse_port(port), do: {:error, {:invalid_format, :port, inspect(port)}}
+
+ defp parse_bind(ip, _) when is_binary(ip) do
+ case :inet.parse_strict_address(String.to_charlist(ip)) do
+ {:ok, ip} -> {:ok, [{:ip, ip}]}
+ {:error, :einval} -> {:error, {:invalid_format, :ip, inspect(ip)}}
+ end
+ end
+ # TODO: Verify ifaddr is a valid interface
+ defp parse_bind(_, ifaddr) when is_binary(ifaddr) do
+ {:ok, [{:ifaddr, ifaddr}]}
+ end
+ defp parse_bind(nil, nil) do
+ {:ok, []}
+ end
+ defp parse_bind(ip, ifaddr) do
+ {:error, {:invalid_format, :bind, inspect([ip, ifaddr])}}
+ end
+
+ defimpl Inspect, for: State do
+ @moduledoc false
+ import Inspect.Algebra
+
+ def inspect(struct, _opts) do
+ status = cond do
+ struct.socket && struct.welcomed -> "registered"
+ struct.socket -> "connecting"
+ true -> "disconnected"
+ end
+ tls = if struct.args.tls, do: "+", else: ""
+ port = ":#{tls}#{struct.args.port}"
+ server = if struct.server do
+ "#{struct.args.host}#{port}[#{struct.server}]"
+ else
+ struct.args.host <> port
+ end
+ nick = struct.nick || struct.args.nick
+ info_string = Enum.join([inspect(self()), status, server, struct.args.user, nick], " ")
+ concat(["#Irc.Connection<", info_string, ">"])
+ end
+ end
+
+end
diff --git a/lib/irc/parser.ex b/lib/irc/parser.ex
new file mode 100644
index 0000000..0224c16
--- /dev/null
+++ b/lib/irc/parser.ex
@@ -0,0 +1,14 @@
+defmodule Irc.Parser do
+ @moduledoc """
+ IRC protocol parser
+
+ The parser is compromised of mulitples modules:
+
+ * `Irc.Parser.Line` handles server-to-client lines,
+ * `Irc.Parser.Numeric` for handling numerics,
+ * `Irc.Parser.Isupport` handles the RPL ISUPPORT format,
+ * `Irc.Parser.Mode` handles the arguments of `MODE` commands.
+
+ """
+
+end
diff --git a/lib/irc/parser/isupport.ex b/lib/irc/parser/isupport.ex
new file mode 100644
index 0000000..9dd7f30
--- /dev/null
+++ b/lib/irc/parser/isupport.ex
@@ -0,0 +1,41 @@
+defmodule Irc.Parser.Isupport do
+ @moduledoc """
+ ISUPPORT list parser
+ """
+
+ def parse(list, acc \\ %{}) do
+ list = List.flatten(list)
+ Enum.reduce(list, acc, fn(entry, acc) ->
+ case String.split(entry, "=", parts: 2) do
+ [entry] ->
+ if String.contains?(entry, " ") do
+ acc
+ else
+ Map.put(acc, entry, true)
+ end
+ [entry, value] -> Map.put(acc, entry, parse_value(value))
+ end
+ end)
+ end
+
+ defp parse_value(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))
+ end
+ end)
+ else
+ format_value(value)
+ end
+ end
+
+ def format_value(value) do
+ case Integer.parse(value) do
+ {i, ""} -> i
+ _ -> value
+ end
+ end
+
+end
diff --git a/lib/irc/parser/line.ex b/lib/irc/parser/line.ex
new file mode 100644
index 0000000..3ba2016
--- /dev/null
+++ b/lib/irc/parser/line.ex
@@ -0,0 +1,143 @@
+defmodule Irc.Parser.Line do
+ @moduledoc """
+ IRC line parser
+ """
+
+ defstruct tags: %{}, source: nil, command: nil, args: []
+
+ @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}
+ end
+
+ # ARGS
+
+ def parse_args(input), do: parse_args(input, [])
+
+ def parse_args(input, acc) do
+ case parse_arg(input, []) do
+ {:continue, new, rest} -> parse_args(rest, [new | acc])
+ {:finished, new} ->
+ list = new ++ acc
+ format_args(list)
+ end
+ end
+
+ # Final argument is the only argument.
+ def parse_arg([?: | rest], acc) when length(acc) == 0 do
+ final = parse_final_arg(rest, [])
+ {:finished, [final]}
+ end
+
+ def parse_arg([0x20, ?: | rest], acc) do
+ final = parse_final_arg(rest, [])
+ {:finished, [final, acc]}
+ end
+
+ def parse_arg([0x20 | rest], acc) do
+ {:continue, acc, rest}
+ end
+
+ def parse_arg([char | rest], acc) do
+ parse_arg(rest, [char | acc])
+ end
+
+ def parse_arg([], acc) do
+ {:finished, [acc]}
+ end
+
+ def parse_final_arg([char | rest], acc) do
+ parse_final_arg(rest, [char | acc])
+ end
+
+ def parse_final_arg([], acc) do
+ acc
+ end
+
+ def format_args(list) when is_list(list) do
+ list
+ |> Enum.map(fn(arg) -> String.trim(acc_to_str(arg)) end)
+ |> Enum.reverse()
+ end
+
+ # COMMAND
+
+ def parse_cmd(input), do: parse_cmd(input, [])
+
+ def parse_cmd([0x20 | rest], acc) do
+ {acc_to_str(acc), rest}
+ end
+
+ def 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}
+
+ def parse_source([0x20 | rest], acc) do
+ {acc_to_str(acc), rest}
+ end
+ def 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}
+
+ def 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
+ parse_tag({[], nil}, input)
+ end
+
+ def parse_tag({acc, nil}, [?= | rest]) do
+ parse_tag({acc, []}, rest)
+ end
+ def parse_tag({acc, nil}, [?; | rest]) do
+ {:continue, {acc, true}, rest}
+ end
+ def parse_tag({acc, nil}, [char | rest]) do
+ parse_tag({[char | acc], nil}, rest)
+ end
+
+ def parse_tag({key, acc}, [?; | rest]) do
+ {:continue, {key, acc}, rest}
+ end
+
+ def parse_tag({key, acc}, [0x20 | rest]) do
+ {:finished, {key, acc}, rest}
+ end
+ def parse_tag({key, acc}, [char | rest]) do
+ parse_tag({key, [char | acc]}, rest)
+ end
+
+ def format_tags(list) do
+ list
+ |> Enum.map(fn({k,v}) ->
+ {acc_to_str(k), acc_to_str(v)}
+ end)
+ |> Enum.into(Map.new)
+ end
+
+ def acc_to_str([]), do: nil
+ def acc_to_str(list) when is_list(list) do
+ list
+ |> Enum.reverse()
+ |> to_string()
+ end
+ def acc_to_str(other), do: other
+
+end
+
diff --git a/lib/irc/parser/mode.ex b/lib/irc/parser/mode.ex
new file mode 100644
index 0000000..d4c0a16
--- /dev/null
+++ b/lib/irc/parser/mode.ex
@@ -0,0 +1,32 @@
+defmodule Irc.Parser.Mode do
+ @moduledoc """
+ Mode change parser
+ """
+
+ def changes(string, previous) do
+ list = to_charlist(string)
+ acc_changes(list, nil, previous)
+ end
+
+ defp acc_changes([?+ | rest], _, acc) do
+ acc_changes(rest, true, acc)
+ end
+
+ defp acc_changes([?- | rest], _, acc) do
+ acc_changes(rest, false, acc)
+ end
+
+ defp acc_changes([0x20 | _], _, acc), do: acc
+
+ defp acc_changes([char | rest], action, acc) do
+ acc = if action do
+ [char | acc]
+ else
+ List.delete(acc, char)
+ end
+ acc_changes(rest, action, acc)
+ end
+
+ defp acc_changes([], _, acc), do: acc
+
+end
diff --git a/lib/irc/parser/numeric.ex b/lib/irc/parser/numeric.ex
new file mode 100644
index 0000000..3a6e27f
--- /dev/null
+++ b/lib/irc/parser/numeric.ex
@@ -0,0 +1,72 @@
+defmodule Irc.Parser.Numeric do
+ require Logger
+
+ @moduledoc """
+ Helpers for using IRC Numerics.
+
+ * Use macros to translate from `rpl_MOTDSTART` (a macro is generated for every numeric name).
+ * Convert a numeric to its name (name_numeric())
+ * Convert a name to its numeric (to_numeric())
+ * Check if a numeric is an error (error_numeric?())
+
+ """
+
+ defmacro __using__(_) do
+ quote do
+ require Irc.Numeric
+ import Irc.Numeric
+ end
+ end
+
+ @numerics File.read!(Path.join(:code.priv_dir(:irc), "numerics.txt"))
+ |> String.split("\n")
+ |> Enum.map(fn(line) ->
+ case String.split(String.trim(line), " ", parts: 2) do
+ ["#"<>_, _] -> nil
+ ["#"<>_] -> nil
+ [numeric, full_name = "ERR_" <> name] -> {numeric, name, true, full_name}
+ [numeric, full_name = "RPL_" <> name] -> {numeric, name, false, full_name}
+ line ->
+ Logger.debug "Invalid line in numeric.txt: #{inspect line}"
+ end
+ end)
+ |> Enum.filter(fn(n) -> n end)
+
+ for {num, name, error, full_name} <- @numerics do
+ prefix = if error, do: "err_", else: "rpl_"
+ fname = "#{prefix}#{name}" |> String.to_atom()
+
+ defmacro unquote(fname)() do
+ unquote(num)
+ end
+
+ def error_numeric?(unquote(num)) do
+ unquote(error)
+ end
+ def error_numeric?(unquote(name)) do
+ unquote(error)
+ end
+ def error_numeric?(unquote(full_name)) do
+ unquote(error)
+ end
+
+ def name_numeric(unquote(num)) do
+ unquote(full_name)
+ end
+
+ def to_numeric(unquote(num)) do
+ unquote(num)
+ end
+ def to_numeric(unquote(name)) do
+ unquote(num)
+ end
+ def to_numeric(unquote(fname)) do
+ unquote(num)
+ end
+ def to_numeric(unquote(full_name)) do
+ unquote(num)
+ end
+
+ end
+
+end
diff --git a/lib/irc/shout.ex b/lib/irc/shout.ex
new file mode 100644
index 0000000..8a3966b
--- /dev/null
+++ b/lib/irc/shout.ex
@@ -0,0 +1,56 @@
+defmodule Irc.Shout do
+ alias Irc.{Connection, Parser.Line}
+
+ def shout(nick, host, target, message, opts \\ []) do
+ opts = opts
+ |> Keyword.put(:reconnect, false)
+ |> Keyword.put_new(:await_up_timeout, :timer.seconds(30))
+ |> Keyword.put_new(:quit, "bye")
+ do_shout(Connection.start(nick, host, opts), target, message, opts)
+ end
+
+ def do_shout({:ok, conn}, target, message, opts) do
+ mon = Process.monitor(conn)
+ result = with \
+ {:ok, _info} <- Connection.await_up(conn, Keyword.get(opts, :await_up_timeout)),
+ :ok <- join(conn, target),
+ :ok <- Connection.sendline(conn, ['PRIVMSG ', target, ' :', message])
+ do
+ :ok
+ else
+ error ->
+ error
+ end
+ Process.demonitor(mon, [:flush])
+ Connection.disconnect(conn, Keyword.get(opts, :quit), true)
+ Connection.flush(conn)
+ result
+ end
+
+ def do_shout(error, _, _, _) do
+ error
+ end
+
+ def join(conn, target = "#"<>_) do
+ case Connection.sendline(conn, ['JOIN ', target]) do
+ :ok -> await_join(conn, target)
+ error -> error
+ end
+ end
+
+ def join(conn, _) do
+ :ok
+ end
+
+ def await_join(conn, target) do
+ receive do
+ {:irc_conn_line, conn, %Irc.Parser.Line{command: "JOIN", args: [target]}} -> :ok
+ {:irc_conn_error, conn, reason} -> {:error, reason}
+ {:DOWN, _, _, conn, reason} -> {:error, {:conn_down, reason}}
+ after
+ 10_000 -> :join_timeout
+ end
+ end
+
+
+end
diff --git a/mix.exs b/mix.exs
new file mode 100644
index 0000000..0f90e99
--- /dev/null
+++ b/mix.exs
@@ -0,0 +1,36 @@
+defmodule Irc.MixProject do
+ use Mix.Project
+
+ def project do
+ [
+ app: :irc,
+ version: "0.1.0",
+ elixir: "~> 1.8",
+ start_permanent: Mix.env() == :prod,
+ deps: deps(),
+ name: "IRC",
+ source_url: "https://github.com/hrefhref/elixir-irc",
+ homepage_url: "https://hrefhref.github.io/elixir-irc",
+ docs: [
+ main: "readme",
+ extras: ["README.md"]
+ ]
+ ]
+ end
+
+ # Run "mix help compile.app" to learn about applications.
+ def application do
+ [
+ extra_applications: [:logger, :ssl],
+ mod: {Irc.Application, []}
+ ]
+ end
+
+ # Run "mix help deps" to learn about dependencies.
+ defp deps do
+ [
+ {:backoff, github: "ferd/backoff"},
+ {:ex_doc, "~> 0.21", only: :dev, runtime: false},
+ ]
+ end
+end
diff --git a/mix.lock b/mix.lock
new file mode 100644
index 0000000..ee01975
--- /dev/null
+++ b/mix.lock
@@ -0,0 +1,8 @@
+%{
+ "backoff": {:git, "https://github.com/ferd/backoff.git", "dfb19a20c37b73af067edc3c335624bdf9517396", []},
+ "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm"},
+ "ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"},
+ "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"},
+ "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"},
+ "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm"},
+}
diff --git a/priv/numerics.txt b/priv/numerics.txt
new file mode 100644
index 0000000..2efb9e4
--- /dev/null
+++ b/priv/numerics.txt
@@ -0,0 +1,101 @@
+001 RPL_WELCOME
+002 RPL_YOURHOST
+003 RPL_CREATED
+004 RPL_MYINFO
+005 RPL_ISUPPORT
+010 RPL_BOUNCE
+221 RPL_UMODEIS
+251 RPL_LUSERCLIENT
+252 RPL_LUSEROP
+253 RPL_LUSERUNKNOWN
+254 RPL_LUSERCHANNELS
+255 RPL_LUSERME
+256 RPL_ADMINME
+257 RPL_ADMINLOC1
+258 RPL_ADMINLOC2
+259 RPL_ADMINEMAIL
+263 RPL_TRYAGAIN
+265 RPL_LOCALUSERS
+266 RPL_GLOBALUSERS
+276 RPL_WHOISCERTFP
+300 RPL_NONE
+301 RPL_AWAY
+302 RPL_USERHOST
+303 RPL_ISON
+305 RPL_UNAWAY
+306 RPL_NOWAWAY
+311 RPL_WHOISUSER
+312 RPL_WHOISSERVER
+313 RPL_WHOISOPERATOR
+314 RPL_WHOWASUSER
+317 RPL_WHOISIDLE
+318 RPL_ENDOFWHOIS
+319 RPL_WHOISCHANNELS
+321 RPL_LISTSTART
+322 RPL_LIST
+323 RPL_LISTEND
+324 RPL_CHANNELMODEIS
+329 RPL_CREATIONTIME
+331 RPL_NOTOPIC
+332 RPL_TOPIC
+333 RPL_TOPICWHOTIME
+341 RPL_INVITING
+346 RPL_INVITELIST
+347 RPL_ENDOFINVITELIST
+348 RPL_EXCEPTLIST
+349 RPL_ENDOFEXCEPTLIST
+351 RPL_VERSION
+353 RPL_NAMREPLY
+366 RPL_ENDOFNAMES
+367 RPL_BANLIST
+368 RPL_ENDOFBANLIST
+369 RPL_ENDOFWHOWAS
+375 RPL_MOTDSTART
+372 RPL_MOTD
+376 RPL_ENDOFMOTD
+381 RPL_YOUROPER
+382 RPL_REHASHING
+400 ERR_UNKNOWNERROR
+401 ERR_NOSUCHNICK
+402 ERR_NOSUCHSERVER
+403 ERR_NOSUCHCHANNEL
+404 ERR_CANNOTSENDTOCHAN
+405 ERR_TOOMANYCHANNELS
+421 ERR_UNKNOWNCOMMAND
+422 ERR_NOMOTD
+431 ERR_NONICKNAMEGIVEN
+432 ERR_ERRONEUSNICKNAME
+433 ERR_NICKNAMEINUSE
+436 ERR_NICKCOLLISION
+441 ERR_USERNOTINCHANNEL
+442 ERR_NOTONCHANNEL
+443 ERR_USERONCHANNEL
+451 ERR_NOTREGISTERED
+461 ERR_NEEDMOREPARAMS
+462 ERR_ALREADYREGISTERED
+464 ERR_PASSWDMISMATCH
+465 ERR_YOUREBANNEDCREEP
+471 ERR_CHANNELISFULL
+472 ERR_UNKNOWNMODE
+473 ERR_INVITEONLYCHAN
+474 ERR_BANNEDFROMCHAN
+475 ERR_BADCHANNELKEY
+481 ERR_NOPRIVILEGES
+482 ERR_CHANOPRIVISNEEDED
+483 ERR_CANTKILLSERVER
+491 ERR_NOOPERHOST
+501 ERR_UMODEUNKNOWNFLAG
+502 ERR_USERSDONTMATCH
+670 RPL_STARTTLS
+691 ERR_STARTTLS
+723 ERR_NOPRIVS
+900 RPL_LOGGEDIN
+901 RPL_LOGGEDOUT
+902 ERR_NICKLOCKED
+903 RPL_SASLSUCCESS
+904 ERL_SASLFAIL
+905 ERL_SASLTOOLONG
+906 ERL_SASLABORTED
+907 ERL_SASLREADY
+908 RPL_SASLMECHS
+
diff --git a/test/irc_test.exs b/test/irc_test.exs
new file mode 100644
index 0000000..8712e31
--- /dev/null
+++ b/test/irc_test.exs
@@ -0,0 +1,8 @@
+defmodule IrcTest do
+ use ExUnit.Case
+ doctest Irc
+
+ test "greets the world" do
+ assert Irc.hello() == :world
+ end
+end
diff --git a/test/parser/line_parser.exs b/test/parser/line_parser.exs
new file mode 100644
index 0000000..efa0194
--- /dev/null
+++ b/test/parser/line_parser.exs
@@ -0,0 +1,45 @@
+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/test_helper.exs b/test/test_helper.exs
new file mode 100644
index 0000000..869559e
--- /dev/null
+++ b/test/test_helper.exs
@@ -0,0 +1 @@
+ExUnit.start()