diff options
30 files changed, 855 insertions, 266 deletions
@@ -1,10 +1,12 @@ -# Irc +# Elixir/Irc -An modern IRC toolkit for Elixir. Compatible IRCv3! +A modern IRC toolkit for Elixir. Compatible IRCv3. Meant to build clients, bouncers, bots, servers, and services. * `Irc.Parser` a versatile IRC parser; * `Irc.Connection` a basic IRC connection (handling registration and socket); -* `Irc.Client` a fully featured and extensible IRC client. +* `Irc.Server` client server (todo); +* `Irc.TS6` TS6 Server (todo); +* `Irc.Client` a fully featured and extensible IRC client (should be usable with `.Connection`, `.Server` or `.TS6`). Examples of usage: `Irc.Shout` (a simple connect-join-message-quit on Connection), `Irc.Client` on BaseClient. @@ -39,4 +41,3 @@ 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 index eaeaf25..07d76aa 100644 --- a/config/config.exs +++ b/config/config.exs @@ -2,3 +2,6 @@ use Mix.Config config :irc, sts_store_file: {:priv, "sts_policies.dets"} + +config :mnesia, + dir: 'priv/#{Mix.env}/#{node()}' diff --git a/lib/irc/addressable.ex b/lib/irc/addressable.ex new file mode 100644 index 0000000..02993b9 --- /dev/null +++ b/lib/irc/addressable.ex @@ -0,0 +1,12 @@ +defprotocol Irc.Addressable do + @moduledoc """ + Protocol for accessing a `nick`, `user`, `host` and `owner` pid and module from any compatible data structure. + """ + + def nick(t) + def user(t) + def host(t) + def owner(t) + def owner_module(t) + +end diff --git a/lib/irc/application.ex b/lib/irc/application.ex index 70b3d0f..cb866e9 100644 --- a/lib/irc/application.ex +++ b/lib/irc/application.ex @@ -7,6 +7,7 @@ defmodule Irc.Application do children = [ {Irc.STS, []} ] + ++ Irc.Store.childs() opts = [strategy: :one_for_one, name: Irc.Supervisor] Supervisor.start_link(children, opts) diff --git a/lib/irc/batch.ex b/lib/irc/batch.ex new file mode 100644 index 0000000..12c8385 --- /dev/null +++ b/lib/irc/batch.ex @@ -0,0 +1,11 @@ +defmodule Irc.Batch do + defstruct [:type, :args, :lines, :tags, :outer, :batches, :source, {:__private__, %{}}] + @type t :: %__MODULE__{ + type: String.t(), + source: Irc.Addressable.t(), + args: [String.t()], + lines: [Irc.Line.t()], + tags: Map.new(), + } + +end diff --git a/lib/irc/client.ex b/lib/irc/client.ex index d8a7b77..b7b9fe0 100644 --- a/lib/irc/client.ex +++ b/lib/irc/client.ex @@ -1,5 +1,7 @@ defmodule Irc.Client do + @behaviour :gen_statem + require Logger alias Irc.Parser.Line alias Irc.Connection @@ -35,6 +37,8 @@ defmodule Irc.Client do def callback_mode, do: [:state_enter] + @type t :: nil + @type message :: {:irc_client, pid(), event} @type event :: event_connected | event_nick | event_modes | event_line | event_down @@ -51,7 +55,6 @@ defmodule Irc.Client do @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()]} @@ -117,7 +120,7 @@ defmodule Irc.Client do end def handle_event(:info, msg = {:irc_conn_up, _, info}, :disconnected, data) do - data = run_handler_event({:connected, info}, data) + data = run_handler_event({:connected, info}, info) {:next_state, :connected, %__MODULE__{data | nick: info.nick, info: info, modes: info.modes, error: nil}} end @@ -147,7 +150,7 @@ defmodule Irc.Client do 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, actions} = case module.handle_command(data.info, command, args) do {next, next_arg} -> {{next, next_arg}, []} {next, next_arg, actions} -> {{next, next_arg}, List.wrap(actions)} next when is_atom(next) -> {{next, nil}, []} @@ -188,7 +191,7 @@ defmodule Irc.Client do # 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, actions} = case module.handle_buffer(data.info, line, data.buffer) do {next, next_arg} -> {{next, next_arg}, []} {next, next_arg, actions} -> {{next, next_arg}, List.wrap(actions)} next when is_atom(next) -> {{next, nil}, []} @@ -205,7 +208,7 @@ defmodule Irc.Client do # TODO: Callback stop? def handle_event(:internal, reason, :error, data) do Logger.error "#{inspect data}: #{inspect reason}" - data.module.stop(reason, data.modstate) + data.module.stop(data.info, reason) {:stop, :normal} end @@ -230,7 +233,7 @@ defmodule Irc.Client do end defp run_handler_event(event, data) do - case data.module.handle_event(event, data.modstate) do + case data.module.handle_event(data.info, event) do {:ok, modstate} -> %__MODULE__{data | modstate: modstate} :ok -> data end @@ -258,7 +261,7 @@ defmodule Irc.Client do end) end - defimpl Inspect, for: __MODULE__ do + defimpl Inspect, for: Irc.Client do import Inspect.Algebra def inspect(struct, _opts) do concat(["#Irc.Client<", inspect(self()), ">"]) diff --git a/lib/irc/client/command.ex b/lib/irc/client/command.ex index a84324f..71b7a6b 100644 --- a/lib/irc/client/command.ex +++ b/lib/irc/client/command.ex @@ -31,7 +31,7 @@ defmodule Irc.Client.Command do @doc """ Handles an incoming line. """ - @callback handle_line(Irc.Line.t(), Irc.Connection.t()) :: line_return + @callback handle_line(conn :: Irc.Connection.t(), line :: Irc.Line.t()) :: line_return @doc """ Handles an incoming line when in buffering state. @@ -40,11 +40,16 @@ defmodule Irc.Client.Command do 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 + @callback handle_buffer(conn :: Irc.Connection.t(), line :: Irc.Line.t(), buffer) :: buffer_return @doc """ Handles a user requested command. """ - @callback handle_command(client_command :: atom, args :: list, Irc.Connection.t()) :: line_return + @callback handle_command(conn :: Irc.Connection.t(), client_command :: atom, args :: list) :: line_return + + @doc """ + Connection is stopped + """ + @callback stop(conn :: Irc.Connection.t(), reason :: atom) :: any() end diff --git a/lib/irc/client/command/account.ex b/lib/irc/client/command/account.ex index c5ec1a9..e0088cd 100644 --- a/lib/irc/client/command/account.ex +++ b/lib/irc/client/command/account.ex @@ -1,17 +1,17 @@ defmodule Irc.Client.Command.Account do - alias Irc.Parser.Line + alias Irc.Line - @type t :: logout :: {:account, Irc.Mask.t} | login :: {:account, Irc.Mask.t, String.t} + #@type t :: (logout :: {:account, Irc.Mask.t}) | (login :: {:account, Irc.Mask.t, String.t}) - def init() do + def init(_) do {"ACCOUNT", nil, "account-notify"} end - def handle_line(%Line{command: "ACCOUNT", source: target, args: ["*"]}) do + 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 + def handle_line(_, %Line{command: "ACCOUNT", source: target, args: [account_name]}) do {:event, {:account, target, account_name}} end diff --git a/lib/irc/client/command/away.ex b/lib/irc/client/command/away.ex index da8b5ef..3941e5b 100644 --- a/lib/irc/client/command/away.ex +++ b/lib/irc/client/command/away.ex @@ -1,13 +1,13 @@ defmodule Irc.Client.Command.Away do - alias Irc.Parser.Line + alias Irc.Line - @type t :: away :: {:away, Irc.Mask.t, String.t} | unaway :: {:away, Irc.Mask.t} + #@type t :: (away :: {:away, Irc.Mask.t, String.t}) | (unaway :: {:away, Irc.Mask.t}) - def init() do + def init(_) do {"AWAY", :away, "away-notify"} end - def handle_command(:away, args, _) do + def handle_command(_, :away, args) do command = case args do [] -> ['AWAY'] [message] -> ['AWAY :', message] @@ -15,7 +15,7 @@ defmodule Irc.Client.Command.Away do {:send, command} end - def handle_line(%Line{command: "AWAY", source: source, args: args}, _) do + def handle_line(_, %Line{command: "AWAY", source: source, args: args}) do case args do [] -> {:event, {:away, source}} [message] -> {:event, {:away, {source, message}}} diff --git a/lib/irc/client/command/chghost.ex b/lib/irc/client/command/chghost.ex index 2b4adab..9a59307 100644 --- a/lib/irc/client/command/chghost.ex +++ b/lib/irc/client/command/chghost.ex @@ -1,13 +1,13 @@ defmodule Irc.Client.Command.Chghost do - alias Irc.Parser.Line + alias Irc.Line @type t :: {:chghost, old_mask :: Irc.Mask.t, new_mask :: Irc.Mask.t} - def init() do + def init(_) do {"CHGHOST", nil, "chghost"} end - def handle_line(%Line{source: old_mask, args: [new_user, new_host]}, _) do + 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 diff --git a/lib/irc/client/command/invite.ex b/lib/irc/client/command/invite.ex index 833c2fc..7a14cb8 100644 --- a/lib/irc/client/command/invite.ex +++ b/lib/irc/client/command/invite.ex @@ -1,21 +1,20 @@ defmodule Irc.Client.Command.Invite do - alias Irc.Parser.Line + alias Irc.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 + def init(_) do {"INVITE", :invite, "invite-notify"} end - def handle_line(%Line{command: "INVITE", source: inviter, args: [invited, channel]}) do + 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 + 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 index f4728c7..f84dc4a 100644 --- a/lib/irc/client/command/join.ex +++ b/lib/irc/client/command/join.ex @@ -1,7 +1,5 @@ defmodule Irc.Client.Command.Join do - alias Irc.Parser.Line - require Line - import Line + alias Irc.Line @type t :: {:join, channel :: String.t()} | {:join, channel :: String.t(), Irc.User.t()} @@ -10,7 +8,7 @@ defmodule Irc.Client.Command.Join do end # An user joined - def handle_line(line = %Line{command: "JOIN", source: source, args: [channel | args]}, conn) do + def handle_line(conn, line = %Line{command: "JOIN", source: source, args: [channel | args]}) do if Line.self?(line, conn) do {:event, {:join, channel}} else @@ -26,12 +24,12 @@ defmodule Irc.Client.Command.Join do end # Join a channel - def handle_command(:join, [channel], _conn_info) do + def handle_command(_, :join, [channel]) 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 + def handle_buffer(%Irc.Connection{nick: nick}, %Line{command: "JOIN", source: %Irc.Mask{nick: nick}, args: [channel]}, buffer = %{channel: channel}) do {:finish, nil, {:event, {:join, channel}}} end diff --git a/lib/irc/client/command/names.ex b/lib/irc/client/command/names.ex index b5a84a6..56ff2e9 100644 --- a/lib/irc/client/command/names.ex +++ b/lib/irc/client/command/names.ex @@ -1,21 +1,21 @@ defmodule Irc.Client.Command.Names do - alias Irc.Parser.Line + alias Irc.Line require Irc.Parser.Numeric import Irc.Parser.Numeric - def init() do + def init(_) do {[rpl_NAMREPLY(), rpl_ENDOFNAMES()], :names, ["userhost-in-names"]} end - def handle_line(%Line{command: rpl_NAMREPLY()}) do + def handle_line(_, %Line{command: rpl_NAMREPLY()}) do :buffer end - def handle_line(%Line{command: rpl_ENDOFNAMES(), args: [_, target | _]}) do + def handle_line(_, %Line{command: rpl_ENDOFNAMES(), args: [_, target | _]}) do :finish end - def handle_buffer(buffer = [%Line{args: [_, target | _]} | _]) do + def handle_buffer(_, buffer = [%Line{args: [_, target | _]} | _]) do {:event, {:names, target, buffer}} end diff --git a/lib/irc/client/command/who.ex b/lib/irc/client/command/who.ex index 1654ef6..41cc761 100644 --- a/lib/irc/client/command/who.ex +++ b/lib/irc/client/command/who.ex @@ -1,5 +1,5 @@ defmodule Irc.Client.Command.Who do - alias Irc.Parser.Line + alias Irc.Line alias Irc.User use Irc.Parser.Numeric @@ -9,7 +9,7 @@ defmodule Irc.Client.Command.Who do {nil, "WHO", nil} end - def handle_command("WHO", [target], conn) do + def handle_command(conn, "WHO", [target]) do args = if Irc.Connection.supports?(conn, "whox") do " nuhs%cuhsnfdar" end @@ -17,12 +17,12 @@ defmodule Irc.Client.Command.Who do end @errors [err_NOSUCHSERVER(), rpl_TRYAGAIN()] - def handle_buffer(%Line{command: error, args: args}, buffer, _conn) when error in @errors do + def handle_buffer(conn, %Line{command: error, args: args}, buffer) when error in @errors do #? {:buffer, %{buffer | error: {error, args}}} end - def handle_buffer(%Line{command: rpl_ENDOFWHO()}, buffer, _conn) do + def handle_buffer(conn, %Line{command: rpl_ENDOFWHO()}, buffer) do result = if buffer.error do {:error, buffer.error} else @@ -31,7 +31,7 @@ defmodule Irc.Client.Command.Who do {:finish, nil, {:event, {:who, buffer.target, result}}} end - def handle_buffer(%Line{command: rpl_WHOREPLY(), args: args}, buffer, conn) do + def handle_buffer(conn, %Line{command: rpl_WHOREPLY(), args: args}, buffer) do case args do [_, c, u, h, s, n, f, d | a_r] -> {a,r} = case a_r do @@ -57,6 +57,6 @@ defmodule Irc.Client.Command.Who do {:buffer, %{buffer | acc: [args | buffer.acc]}} end - def handle_buffer(_, _), do: :postpone + def handle_buffer(_, _, _), do: :postpone end diff --git a/lib/irc/connection.ex b/lib/irc/connection.ex index 23eee3f..6df8773 100644 --- a/lib/irc/connection.ex +++ b/lib/irc/connection.ex @@ -2,7 +2,7 @@ defmodule Irc.Connection do @behaviour :gen_statem require Logger alias Irc.ConnectionSocket - alias Irc.Parser.Line + alias Irc.Line alias Irc.Parser require Irc.Parser.Numeric import Irc.Parser.Numeric @@ -15,6 +15,7 @@ defmodule Irc.Connection do @registration_timeout :timer.seconds(30) @connection_capabs ["sasl", "cap-notify"] @internal {:next_event, :internal, nil} + @default_handler Irc.Connection.MsgHandler @moduledoc """ Lightweight implementation of an IRC Connection handling only the basics: @@ -27,26 +28,34 @@ defmodule Irc.Connection do * nick and user mode changes, * batches/echo. - Events and lines are sent to calling PID. + An implementation of `Irc.Connection.Handler` should be provided to interact with the connection. + + By default, the `Irc.Connection.MsgHandler` is used, which sends events to the owner process. ## Usage - The client is started using `start/4` or `start_link/4`, supplying a nickname, IRC host, and options `t:start_opt/0`: + The connection is started using `start/4` or `start_link/4`, supplying a nickname, IRC host, and options `t:start_opt/0`: ```elixir - {:ok, Pid} = Irc.Connection.start("ExIrcTest", "irc.random.sh", [tls: true, port: 6697], []) + {:ok, pid} = Irc.Connection.start("ExIrcTest", "irc.random.sh", [tls: true, port: 6697], []) ``` - Messages will be sent to your PID, see `t:message/0` for the possible types. + This will start a connection using the default handler: events will be sent to your PID, see `t:Irc.Connection.MsgHandler.message/0` for the possible types. You can send lines using `sendline/3`, and disconnect with `disconnect/3`. Status and information about the connection (`t:t/0`) can be requested with `info/1`. - If you are looking for a more featured client, you probably want `Irc.Client`! + To start a connection with a custom handler, you need to set the `handler` and `handler_opts` start options: + + ```elixir + {:ok, pid} = Irc.Connection.start("ExIrcTest", "irc.random.sh", [tls: true, port: 6697, handler: MyHandler, handler_opts: %{:something => true}], []) + ``` + + If you are looking for a more featured client: you probably want `Irc.Client`. """ - defstruct [:server, :host, :port, :tls, :sts, :nick, :modes, :capabs, :server_capabs, :version, :umodes, :cmodes, :cmodes_with_params, :isupport, :motd] + defstruct [:pid, :server, :host, :port, :tls, :sts, :nick, :modes, :capabs, :server_capabs, :version, :umodes, :cmodes, :cmodes_with_params, :isupport, :motd, :assigns, :__private__] @typedoc """ Connection Information state. Retrieve it with `info/1`. @@ -82,35 +91,9 @@ defmodule Irc.Connection do cmodes_with_params: String.t, isupport: Irc.Parser.Isupport.t(), motd: [String.t], + assigns: Map.t() } - @typedoc "Messages sent to the owning process." - @type message :: msg_up() | msg_down() | msg_error() - | msg_line() | msg_batch() | msg_umodes() | msg_nick() - - @typedoc "Connection is now up and registered." - @type msg_up :: {:irc_conn_up, pid(), t()} - @typedoc "Message: Received an IRC line." - @type msg_line :: {:irc_conn_line, pid(), Line.t} - @typedoc "Message: Line batch (only if enabled, see `batch` in `t:start_opt/0`)." - @type msg_batch :: {:irc_conn_batch, pid(), type :: String.t(), type_params :: [String.t()], lines :: [Line.t]} - @typedoc "Message: Connection user modes have changed." - @type msg_umodes :: {:irc_conn_modes, pid(), changes :: String.t, previous_modes :: list(), modes :: list()} - @typedoc "Message: Connection nickname have changed." - @type msg_nick :: {:irc_conn_nick, pid(), previous_nick :: String.t, new_nick :: String.t} - @typedoc "Message: Connection is down and will try to reconnect in `delay`." - @type msg_down :: {:irc_conn_down, pid(), error, delay :: integer} - @typedoc "Message: Connection encountered a fatal error and stopped." - @type msg_error :: {:irc_conn_error, pid(), error} - - @typedoc "Connection error reasons" - @type error :: {:error, error_irc | any} - - @typedoc "IRC Error reasons" - @type error_irc :: {:killed, by :: String.t, reason :: String.t} - | {:quit, reason :: String.t} - | {:irc_error, reason :: String.t} - # TODO: Better batch / Echo-message / message id # TODO: CAP lifecycle while connection is established # TODO: SASL @@ -131,7 +114,8 @@ defmodule Irc.Connection do * [webirc][webirc] options, see `t:start_opt_webirc/0`, * capabs: list to request, * connect options (either `t:gen_tcp.connect_option/0` or `t:ssl.start_option/0`, depending on the value of the `tls`), - * owner process. + * owner process, + * handler_module and handler_args. """ @type start_opt :: {:user, nil | String.t} @@ -148,6 +132,8 @@ defmodule Irc.Connection do | {:capabs, [String.t()]} | {:connect_opts, [:gen_tcp.connect_option | :ssl.connect_option]} | {:process, pid()} + | {:handler, Irc.Connection.MsgHandler | module()} + | {:handler_args, %{}} @typedoc """ IRCv3 [Strict Transport Security]() @@ -161,7 +147,7 @@ defmodule Irc.Connection do [sasl]: https://ircv3.net/specs/extensions/sasl-3.1.html """ - @type start_opt_sasl :: %{mechanism :: String.t => [mechanism_param() :: String.t]} + @type start_opt_sasl :: %{mechanism :: String.t => [mechanism_param :: String.t]} @typedoc """ IRCv3 [Batch][batch] support. @@ -301,17 +287,20 @@ defmodule Irc.Connection do defmodule State do @moduledoc false - defstruct [:args, - :backoff, :socket, - :process, :monitor, - :nick, - :server, - :version, - :umodes, - :cmodes, - :cmodes_with_params, - :welcomed, - :tls, + defstruct [ + :args, # Start arguments + :backoff, # Backoff state + :socket, # ConnectionSocket + :process, # Owner PID + :monitor, # Owner monitor + :nick, # Current nickname + :server, # Server name + :version, # Server version + :umodes, # Server umodes + :cmodes, # Server cmodes + :cmodes_with_params, # Server parametized cmodes + :welcomed, # Registration + :tls, # Connected with TLS :cap_302, # If server supports 302 :sts, # If an STS policy has been used :port, # Actual connection port (may differ than args.port due to STS) @@ -323,7 +312,9 @@ defmodule Irc.Connection do {:client_capabs, []}, # Client enabled capabs {:capabs, []}, # Negotiated capabs {:motd, []}, - {:welcome, []}, # Registration log + {:welcome, []}, # Registration log, + {:assigns, %{}}, + {:__private__, %{}} ] end @@ -331,32 +322,34 @@ defmodule Irc.Connection do def callback_mode, do: [:state_functions, :state_enter] @doc false - def init([args]) do - backoff_min = Map.get(args, :backoff_min, @backoff_min) - backoff_max = Map.get(args, :backoff_max, @backoff_max) - process = Map.get(args, :process, nil) + def init([start_args]) do + process = Map.get(start_args, :process, nil) monitor = Process.monitor(process) with \ - {:ok, args} <- process_args(args), - backoff <- :backoff.init(backoff_min, backoff_max) + backoff <- init_backoff(start_args), + {:ok, args} <- process_args(start_args), + {tls, sts, port} <- init_sts(args), + {:ok, handler_args} <- handler_init(args), + {:ok, handler_capabs} <- handler_capabs(args, handler_args) do - {tls, sts, port} = case {args.sts, Irc.STS.lookup(args.host)} do - {true, {:ok, port}} -> {true, true, port} - _ -> {args.tls, false, args.port} - end - client_capabs = [Irc.Parser.capabs(), - @connection_capabs, - if(args.batch, do: ["batch"]), - if(args.sasl, do: ["sasl"]), - if(args.echo, do: ["echo-message"]), - args.capabs, - ] - |> Enum.filter(fn(i) -> i end) - |> List.flatten() - |> Enum.uniq() + client_capabs = [ + Irc.Parser.capabs(), + @connection_capabs, + if(args.batch, do: ["batch"]), + if(args.sasl, do: ["sasl"]), + if(args.echo, do: ["echo-message"]), + args.capabs, + handler_capabs + ] + |> Enum.filter(fn(i) -> i end) + |> List.flatten() + |> Enum.uniq() + data = %State{ args: args, - sts: sts, port: port, tls: tls, + sts: sts, + port: port, + tls: tls, client_capabs: client_capabs, process: process, monitor: monitor, @@ -375,7 +368,7 @@ defmodule Irc.Connection do def connect(:internal, _, data) do Logger.debug("#{inspect(data)} connecting...") options = [{:active, true}, {:packet, :line}] ++ data.args.connect_opts - case ConnectionSocket.connect(data.tls, data.args.host, data.port, options) do + case ConnectionSocket.connect(data.tls, data.args.host, data.port, options, __MODULE__) do {:ok, socket} -> {_, backoff} = :backoff.succeed(data.backoff) {:next_state, :reg_webirc, %State{data | socket: socket, backoff: backoff}, @internal} @@ -516,13 +509,14 @@ defmodule Irc.Connection do @doc false def reg_nick(:internal, nick, data) do - case socksend(data, Line.new("NICK", nick || data.args.nick)) do + nick = nick || data.nick || data.args.nick + case socksend(data, Line.new("NICK", nick)) do :ok -> {:keep_state, %State{data | nick: nick}, {:state_timeout, @reply_timeout, nil}} error -> to_disconnected(error, data) end end - def reg_nick(:info, {_, _, %Line{command: cmd}} = line, data) when cmd in [err_NICKNAMEINUSE(), err_NICKCOLLISION() ]do + def reg_nick(:info, {_, _, %Line{command: cmd} = line}, data) when cmd in [err_NICKNAMEINUSE(), err_NICKCOLLISION() ]do Logger.debug "#{inspect(data)} <<< #{inspect(line)}" new_nick = data.args.nick <> to_string(:random.uniform(9999)) {:keep_state, data, {:next_event, :internal, new_nick}} @@ -669,9 +663,9 @@ defmodule Irc.Connection do {:next_state, :registered, %State{data | motd: []}} end - def motd(:info, {_, _, %Line{command: rpl_MOTDSTART(), args: [_, line]} = line}, data) do + def motd(:info, {_, _, %Line{command: rpl_MOTDSTART(), args: [_, motd_line]} = line}, data) do Logger.debug "#{inspect(data)} <<< #{inspect(line)}" - {:keep_state, %State{data | motd: [line]}, {:state_timeout, @reply_timeout, nil}} + {:keep_state, %State{data | motd: [motd_line]}, {:state_timeout, @reply_timeout, nil}} end def motd(:info, {_, _, %Line{command: rpl_MOTD(), args: [_, motd_line]} = line}, data) do @@ -696,8 +690,9 @@ defmodule Irc.Connection do defp to_info(data, state \\ nil) do data |> Map.from_struct() - |> Map.take([:nick, :port, :tls, :sts, :modes, :server, :version, :umodes, :cmodes, :cmodes_with_params, :capabs, :isupport, :motd]) + |> Map.take([:nick, :port, :tls, :sts, :modes, :server, :version, :umodes, :cmodes, :cmodes_with_params, :capabs, :isupport, :motd, :assigns, :__private__]) |> Map.put(:host, data.args.host) + |> Map.put(:pid, self()) |> Map.put(:__struct__, __MODULE__) end @@ -710,8 +705,9 @@ defmodule Irc.Connection do motd: Enum.reverse(data.motd), welcomed: true } - msg = {:irc_conn_up, self(), to_info(data, :registered)} - send(data.process, msg) + {:ok, data} = handler_run_event(data, :connected, [:TODO_PASS_ARGS]) + #msg = {:irc_conn_up, self(), to_info(data, :registered)} + #send(data.process, msg) Logger.info("#{inspect(data)} connected and registered") {:next_state, :registered, data, @conn_ping_timeout} end @@ -720,9 +716,8 @@ defmodule Irc.Connection do Logger.debug "#{inspect(data)} <<< #{inspect(line)}" case process_line(line, data) do :ignore -> - msg = {:irc_conn_line, self(), line} - send(data.process, msg) - :keep_state_and_data + {:ok, data} = handler_run_event(data, :line, [line]) + {:keep_state, data} return -> return end @@ -766,8 +761,9 @@ defmodule Irc.Connection do to_disconnected({:killed, source, reason}, data) end - def process_line(%Line{command: "QUIT", source: source, args: [reason]}, data = %{nick: nick}) do - if String.starts_with?(source, nick<>"!") do + def process_line(%Line{command: "QUIT", source: source, args: [reason]} = line, data = %{nick: nick}) do + if Line.to?(line, to_info(:registered, data)) do + #if String.starts_with?(source, nick<>"!") do to_disconnected({:quit, source, reason}, data) else :ignore @@ -790,35 +786,60 @@ defmodule Irc.Connection do end def process_line(%Line{command: "MODE", args: [nick, changes]}, data = %{nick: nick}) do + prev = data.modes modes = Irc.Parser.Mode.changes(changes, data.modes) - send(data.process, {:irc_conn_modes, self(), changes, data.modes, modes}) - {:keep_state, %State{data | modes: modes}} + {:ok, data} = %State{data | modes: modes} + |> handler_run_event(:modes, [changes, prev]) + {:keep_state, data} end def process_line(%Line{command: "NICK", args: [new_nick]}, data = %{nick: nick}) do - send(data.process, {:irc_conn_nick, self(), nick, new_nick}) + {:ok, data} = %State{data | nick: new_nick} + |> handler_run_event(:nick, [nick, new_nick]) {:keep_state, %State{data | nick: nick}} end # -- BATCH - def process_line(%Line{command: "BATCH", args: ["+"<>batchid, type | args]}, data) do - batch = %{type: type, args: args, lines: []} - {:keep_state, %State{data | batches: Map.put(data.batches, batchid, batch)}} + # TODO: Improve batch - support nested in nested, ... + def process_line(line = %Line{command: "BATCH", args: ["+"<>batchid, type | args], tags: tags}, data) do + {batches, outer_id} = if outer_batchid = Map.get(tags, "batch") do + if outer = Map.get(data.batches, outer_batchid) do + outer = Map.put(outer, :batches, [batchid | outer.batches]) + {Map.put(data.batches, outer_batchid, outer), outer_batchid} + end + else + {data.batches, nil} + end + batch = %{type: type, args: args, lines: [], tags: tags, outer: outer_id, batches: [], source: line.source} + {:keep_state, %State{data | batches: Map.put(batches, batchid, batch)}} end - def process_line(%Line{command: "BATCH", args: ["-"<>batchid | args]}, data) do - %{type: type, args: args, lines: lines} = Map.get(data.batches, batchid) - if Keyword.get(data.args, :batch, true) do - send(data.process, {:irc_conn_batch, self(), type, args, Enum.reverse(lines)}) + def process_line(line = %Line{command: "BATCH", args: ["-"<>batchid | args]}, data) do + %{type: type, args: args, lines: lines, tags: tags, outer: outer_id, batches: batches} = Map.get(data.batches, batchid) + if outer_id do + :keep_state_and_data else - for l <- Enum.reverse(lines), do: send(data.process, {:irc_conn_line, self(), l}) + for batchid <- [batchid | Enum.reverse(batches)] do + %{type: type, args: args, lines: lines, tags: tags} = batch = Map.get(data.batches, batchid) + batch = batch + |> Map.put(:lines, Enum.reverse(lines)) + |> Map.put(:__private__, line.__private__) + batch = struct(Irc.Batch, batch) + + if Keyword.get(data.args, :batch, true) do + {:ok, data} = handler_run_event(data, :batch, batch) + else + for l <- Enum.reverse(lines), do: send(data.process, {:irc_conn_line, self(), l}) + end + end + {:keep_state, %State{data | batches: Map.drop(data.batches, batchid)}} end - {:keep_state, %State{data | batches: Map.drop(data.batches, batchid)}} end def process_line(line = %Line{tags: %{"batch" => batch}}, data) do if batch_data = Map.get(data.batches, batch) do - batch_data = Map.put(batch_data, :lines, [line | batch_data.lines]) + tags = Map.drop(line.tags, "batch") + batch_data = Map.put(batch_data, :lines, [%{line | tags: tags} | batch_data.lines]) {:keep_state, %State{data | batches: Map.put(data.batches, batch, batch_data)}} else :ignore @@ -854,7 +875,7 @@ defmodule Irc.Connection do else data end - data = %State{data | welcomed: false, server: nil, nick: nil, welcome: [], motd: [], isupport: %{}, backoff: backoff} + data = %State{data | welcomed: false, server: nil, welcome: [], motd: [], isupport: %{}, backoff: backoff} Logger.error("#{inspect data} - #{inspect error} - (reconnecting in #{inspect delay})") {:keep_state, data, {:state_timeout, delay, error}} end @@ -935,7 +956,9 @@ defmodule Irc.Connection do {:keep_state_and_data, {:reply, ref, {:error, :not_disconnected}}} end def handle_common(_, {:call, ref}, {:send, _}, _) do + # TODO: Decide if we should postpone or blanket refuse. {:keep_state_and_data, {:reply, ref, {:error, :not_connected}}} + #{:keep_state_and_data, :postpone} end def handle_common(_, :cast, {:send, _}, _) do @@ -995,7 +1018,7 @@ defmodule Irc.Connection do # Send helpers. defp socksend(data, line = %Line{}) do - socksend(data, [Line.encode(line, to_info(data))]) + socksend(data, [Parser.Line.encode(line, to_info(data))]) end defp socksend(data = %State{socket: socket}, line) when is_list(line) do Logger.debug("#{inspect data} >>> #{inspect line}") @@ -1008,6 +1031,24 @@ defmodule Irc.Connection do {:next_state, :disconnected, data, {:next_event, :internal, error}} end + # -- Init helpers + defp init_backoff(start_args) do + min = Map.get(start_args, :backoff_min, @backoff_min) + max = Map.get(start_args, :backoff_max, @backoff_max) + :backoff.init(min, max) + end + + @spec init_sts(Map.new) :: {use_tls :: boolean, active_sts_policy :: boolean, port :: non_neg_integer} + defp init_sts(args = %{sts: true}) do + case Irc.STS.lookup(args.host) do + {:ok, port} -> {true, true, port} + _ -> init_sts(%{args | sts: false}) + end + end + defp init_sts(%{tls: tls, port: port}) do + {tls, false, port} + end + # -- Arguments processing/validation # Prepare args (in start) @@ -1037,6 +1078,8 @@ defmodule Irc.Connection do sasl = Map.get(args, :sasl, false) echo = Map.get(args, :echo, true) connect_opts = Map.get(args, :connect_opts) || [] + handler = Map.get(args, :handler, @default_handler) + handler_opts = Map.get(args, :handler_opts, []) with \ {:nick, :ok} <- {:nick, verify_nickname(nick)}, @@ -1058,7 +1101,9 @@ defmodule Irc.Connection do sasl: sasl, echo: echo, connect_opts: connect_opts, - connect_timeout: connect_timeout + connect_timeout: connect_timeout, + handler: handler, + handler_opts: handler_opts }} else error -> error @@ -1081,6 +1126,78 @@ defmodule Irc.Connection do defp verify_nickname(nick), do: {:error, {:invalid_nickname, nick}} + defp handler_init(args) do + args.handler.init(args) + end + + def handler_capabs(args, h_args) do + args.handler.capabs(h_args) + end + + defp handler_run_event(data = %{handler: handler}, callback, args) do + args = [to_info(:connected, data) | args] + case :erlang.apply(handler, callback, args) do + {:ok, info} -> + {:ok, %State{data | assigns: info.assigns, __private__: info.__private__}} + error -> + error + end + end + + defimpl Irc.Context, for: __MODULE__ do + def capab?(conn, capab) do + Enum.member?(conn.capabs, capab) + end + + def capabs(conn) do + conn.capabs + end + + def pid(conn) do + conn.pid + end + + def module(conn) do + conn.__struct__ + end + + end + + defimpl Irc.Addressable, for: __MODULE__ do + def nick(conn), do: conn.nick + def user(_), do: nil + def host(_), do: nil + def owner(conn), do: conn.pid + def owner_module(conn), do: conn.__struct__ + end + + defimpl Irc.Context, for: State do + def capab?(state, capab) do + Enum.member?(state.capabs, capab) + end + + def capabs(state) do + state.capabs + end + + def pid(_) do + self() + end + + def module(state) do + Irc.Connection + end + + end + + defimpl Irc.Addressable, for: State do + def nick(state), do: state.nick + def user(_), do: nil + def host(_), do: nil + def owner(_), do: self() + def owner_module(), do: Irc.Connection + end + defimpl Inspect, for: State do @moduledoc false import Inspect.Algebra diff --git a/lib/irc/connection_handler.ex b/lib/irc/connection_handler.ex new file mode 100644 index 0000000..a5d5f3f --- /dev/null +++ b/lib/irc/connection_handler.ex @@ -0,0 +1,50 @@ +defmodule Irc.Connection.Handler do + @moduledoc """ + Handler behaviour + """ + + @type args :: Map.new() + + @doc """ + Initializes + """ + @callback init(args()) :: args() + + @doc """ + Capability negotiation. + + Allows the handler to request more capabilities from the server. + """ + @callback capabs(args()) :: [String.t()] + + @doc """ + Connected callback. + """ + @callback connected(conn :: Irc.Connection.t(), args()) :: {:ok, conn :: Irc.Connection.t()} + + @doc """ + An IRC line has been received. + """ + @callback line(conn :: Irc.Connection.t(), line :: Irc.Line.t()) :: {:ok, conn :: Irc.Connection.t()} + + @doc """ + An IRC batch has been received. + """ + @callback batch(conn :: Irc.Connection.t(), batch :: Irc.Batch.t()) :: {:ok, conn :: Irc.Connection.t()} + + @doc """ + Connection changed modes. + """ + @callback modes(conn :: Irc.Connection.t(), changes :: :todo, prev :: :todo) :: {:ok, conn :: Irc.Connection.t()} + + @doc """ + Connection changed nick. + """ + @callback nick(conn :: Irc.Connection.t(), prev_nick :: String.t, new_nick :: String.t) :: {:ok, conn :: Irc.Connection.t()} + + @doc """ + Disconnected callback. + """ + @callback disconnected(conn :: Irc.Connection.t(), reason :: atom()) :: any() + +end diff --git a/lib/irc/connection_msg_h.ex b/lib/irc/connection_msg_h.ex new file mode 100644 index 0000000..d0cf2ba --- /dev/null +++ b/lib/irc/connection_msg_h.ex @@ -0,0 +1,84 @@ +defmodule Irc.Connection.MsgHandler do + @behaviour Irc.Connection.Handler + @moduledoc """ + Default behaviour. Sends events as messages to owner process. + """ + + @typedoc "Messages sent to the owning process." + @type message :: msg_up() | msg_down() | msg_error() + | msg_line() | msg_batch() | msg_umodes() | msg_nick() + + + @typedoc "Connection is now up and registered." + @type msg_up :: {:irc_conn_up, pid(), Irc.Connection.t()} + @typedoc "Message: Received an IRC line." + @type msg_line :: {:irc_conn_line, pid(), Irc.Line.t()} + @typedoc "Message: Line batch (only if enabled, see `batch` in `t:start_opt/0`)." + @type msg_batch :: {:irc_conn_batch, pid(), Irc.Batch.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 "IRC Error reasons" + @type error_irc :: {:killed, by :: String.t, reason :: String.t} + | {:quit, reason :: String.t} + | {:irc_error, reason :: String.t} + + + @typedoc "Connection error reasons" + @type error :: {:error, error_irc | any} + + @impl true + def init(%{process: process}) do + {:ok, process} + end + + @impl true + def capabs(_process) do + {:ok, []} + end + + @impl true + def connected(conn, process, _) do + msg = {:irc_conn_up, self(), conn} + send(conn.process, msg) + {:ok, conn} + end + + @impl true + def line(conn, line) do + msg = {:irc_conn_line, self(), line} + send(conn.process, msg) + {:ok, conn} + end + + @impl true + def batch(conn, batch) do + send(conn.process, {:irc_conn_batch, self(), batch}) + {:ok, conn} + end + + @impl true + def modes(conn, changes, prev) do + send(conn.process, {:irc_conn_modes, self(), changes, conn.modes}) + {:ok, conn} + end + + @impl true + def nick(conn, prev_nick, new_nick) do + send(conn.process, {:irc_conn_nick, self(), prev_nick, new_nick}) + {:ok, conn} + end + + @impl true + def disconnected(conn, _) do + nil + end + +end diff --git a/lib/irc/connection_socket.ex b/lib/irc/connection_socket.ex index 29c42c0..d7169fd 100644 --- a/lib/irc/connection_socket.ex +++ b/lib/irc/connection_socket.ex @@ -11,10 +11,10 @@ defmodule Irc.ConnectionSocket do @type t :: {ConnectionSocket, pid(), reference()} - @spec connect(tls :: boolean, host :: String.t, port :: Integer.t, options :: Keyword.t) :: {:ok, t} | {:error, error} - def connect(tls, host, port, options) do + @spec connect(tls :: boolean, host :: String.t, port :: Integer.t, options :: Keyword.t, module()) :: {:ok, t} | {:error, error} + def connect(tls, host, port, options, module) do caller = self() - case GenServer.start(__MODULE__, [caller, tls, host, port, options]) do + case GenServer.start(__MODULE__, [caller, tls, host, port, options, module]) do {:ok, pid} -> mon = Process.monitor(pid) {:ok, {__MODULE__, pid, mon}} @@ -38,10 +38,10 @@ defmodule Irc.ConnectionSocket do end @doc false - def init([caller, tls, host, port, options]) do + def init([caller, tls, host, port, options, caller_module]) do Process.monitor(caller) module = if tls, do: :ssl, else: :gen_tcp - state = %{caller: caller, module: module, host: host, port: port, options: options, socket: nil} + state = %{caller: caller, caller_module: caller_module, module: module, host: host, port: port, options: options, socket: nil} case module.connect(String.to_charlist(host), port, options) do {:ok, socket} -> {:ok, Map.put(state, :socket, socket)} @@ -70,7 +70,7 @@ defmodule Irc.ConnectionSocket do @doc false # Received a line from socket def handle_info({module, socket, line}, state = %{caller: caller, module: module, socket: socket}) do - Kernel.send(caller, {__MODULE__, self(), Line.parse(line)}) + Kernel.send(caller, {__MODULE__, self(), Line.parse(line, state.caller_module, state.caller)}) {:noreply, state} end diff --git a/lib/irc/context.ex b/lib/irc/context.ex new file mode 100644 index 0000000..017ea5f --- /dev/null +++ b/lib/irc/context.ex @@ -0,0 +1,16 @@ +defprotocol Irc.Context do + @doc """ + Provides a global IRC context. + """ + + @spec capab?(t(), capability :: String.t()) :: boolean + def capab?(t, capability) + + @spec capabs(t()) :: [String.t()] + def capabs(t) + + def pid(t) + + def module(t) + +end diff --git a/lib/irc/data.ex b/lib/irc/data.ex new file mode 100644 index 0000000..4d48906 --- /dev/null +++ b/lib/irc/data.ex @@ -0,0 +1,10 @@ +defmodule Irc.Data do + @moduledoc """ + The `Irc` library contains a polymorphic data system. + + * Universal structures: `Irc.User`, `Irc.Mask`, `Irc.Line`; + * Data protocols: `Irc.Addressable`, `Irc.Context` + + Data structures and protocol requires the owner/creator of theses structures to attach its identity (pid and module). + """ +end diff --git a/lib/irc/line.ex b/lib/irc/line.ex new file mode 100644 index 0000000..56836a8 --- /dev/null +++ b/lib/irc/line.ex @@ -0,0 +1,69 @@ +defmodule Irc.Line do + @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: [] + + def new(command) do + new(command, []) + end + + def new(command, args) do + new(command, args, %{}) + end + + def new(command, arg, tags) when not is_list(arg) do + new(command, [arg], tags) + 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.Addressable.t()) :: boolean + @doc "Returns true if the line is adressed to the connection." + def to?(%__MODULE__{args: [nick | _]}, addressable) do + Irc.Addressable.nick(addressable) == nick + end + + @spec self?(t(), Irc.Addressable.t()) :: boolean + @doc "Returns true if the line source is the from the given connection/mask." + def self?(%__MODULE__{source: nick}, addressable) when is_binary(nick) do + Irc.Addressable.nick(addressable) == nick + end + def self?(%__MODULE__{source: source_addressable}, addressable) do + Irc.Addressable.equal?(source_addressable, addressable) + end + + defimpl String.Chars, for: __MODULE__ do + def to_string(line) do + Irc.Parser.Line.encode(line) + end + end + + defimpl Inspect, for: __MODULE__ do + @moduledoc false + import Inspect.Algebra + + def inspect(struct, _opts) do + tags = Enum.map(struct.tags, fn({k, v}) -> concat([k, "=", v]) end) |> Enum.join(",") + Enum.join(["#IRC.Line<", struct.source, " ", struct.command, " ", inspect(struct.args), " (", tags, ")>"], "") + end + end + + +end diff --git a/lib/irc/mask.ex b/lib/irc/mask.ex index 5106c26..7f68d1a 100644 --- a/lib/irc/mask.ex +++ b/lib/irc/mask.ex @@ -1,7 +1,8 @@ defmodule Irc.Mask do + alias Irc.Addressable @type t :: %__MODULE__{nick: nil | String.t(), user: nil | String.t(), host: nil | String.t(), server: nil | String.t()} - defstruct [:nick, :user, :host, :server] + defstruct [:nick, :user, :host, :server, {:__private__, %{owner: nil, owner_module: nil}}] @spec parse(String.t) :: {:ok, t()} | {:error, :invalid_mask} def parse(string) do @@ -12,10 +13,39 @@ defmodule Irc.Mask do end end + def parse(string, module, pid) do + case parse(string) do + {:ok, x} -> {:ok, set_owner(x, module, pid)} + error -> error + end + end + + @spec equal?(Addressable.t(), Addressable.t()) :: boolean + def equal?(a, b) do + import Addressable + (owner(a) == owner(b)) && (nick(a) == nick(b)) + end + + def from_addressable(addressable) do + import Addressable + new(nick(addressable), + user(addressable), + host(addressable) + ) + |> set_owner(owner_module(addressable), owner(addressable)) + end + def new(nick, user, host) do %__MODULE__{nick: nick, user: user, host: host} end + def set_owner(t = %{__private__: private = %{owner: nil, owner_module: nil}}, module, pid) when is_atom(module) and is_pid(pid) do + private = private + |> Map.put(:owner, pid) + |> Map.put(:owner_module, module) + %{t | __private__: private} + end + def to_string(%__MODULE__{server: nil, nick: nick, user: user, host: host}) do Enum.join([nick, "!", user, "@", host]) end @@ -40,6 +70,12 @@ defmodule Irc.Mask do defdelegate to_string(struct), to: Irc.Mask end + defimpl Irc.Addressable, for: __MODULE__ do + def nick(%{nick: nick}), do: nick + def user(%{user: user}), do: user + def host(%{host: host}), do: host + end + defimpl Inspect, for: __MODULE__ do @moduledoc false import Inspect.Algebra diff --git a/lib/irc/parser/capabs.ex b/lib/irc/parser/capabs.ex index 88bf017..1448d75 100644 --- a/lib/irc/parser/capabs.ex +++ b/lib/irc/parser/capabs.ex @@ -1,7 +1,10 @@ defmodule Irc.Parser.Capabs do @moduledoc "Helper to parse capability lists" + @type t :: %{capab_name() => capab_value()} + @type capab_name :: String.t() + @type capab_value :: true | any() - @spec parse(String.t) :: %{capab :: String.t => true | args :: Map.t} + @spec parse(String.t()) :: t() def parse(string) do string |> String.split(" ") @@ -29,4 +32,3 @@ defmodule Irc.Parser.Capabs do end end - diff --git a/lib/irc/parser/line.ex b/lib/irc/parser/line.ex index 48ddd9c..b436c57 100644 --- a/lib/irc/parser/line.ex +++ b/lib/irc/parser/line.ex @@ -2,93 +2,58 @@ defmodule Irc.Parser.Line do @moduledoc """ IRC line parser/encoder """ + alias Irc.Line + require Logger - @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() + @spec parse(string) :: Line.t() @doc "Parse a server-to-client line." - def parse(line) do + def parse(line, owner_module \\ nil, owner \\ nil) + + def parse(line, context, _) when is_map(context) do + module = Irc.Context.module(context) + pid = Irc.Context.pid(context) + parse(line) + |> set_owner(module, pid) + end + + def parse(line, owner_module, owner) do {tags, rest} = parse_tags(line) - {source, rest} = parse_source(rest) + {source, rest} = parse_source(rest, owner_module, owner) {command, rest} = parse_cmd(rest) args = parse_args(rest) private = %{ + owner: owner, + owner_module: owner_module, at: DateTime.utc_now() } - %__MODULE__{__private__: private, tags: tags, source: source, command: command, args: args} + %Line{__private__: private, tags: tags, source: source, command: command, args: args} end - @spec encode(t()) :: String.t + @spec set_owner(Line.t(), module(), pid()) :: Line.t() + def set_owner(line = %Line{__private__: private = %{owner: nil, owner_module: nil}}, module, owner) when is_atom(module) and is_pid(owner) do + private = Map.put(private, :owner_module, module) + |> Map.put(:owner, owner) + %Line{line | __private__: private} + end + + def set_owner(line = %Line{__private__: private = %{owner: opid, owner_module: omod}}, module, owner) when is_atom(module) and is_pid(owner) do + Logger.debug("Parser.Line: tried to re-own an already owned line #{inspect {{omod,opid},{module,owner}}}") + line + end + + @spec encode(Line.t()) :: String.t @doc "Encode a line" - def encode(line = %__MODULE__{}, conn \\ %Irc.Connection{}) do + def encode(line = %Line{}, 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 = if Irc.Context.capab?(conn, "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) do - new(command, []) - end - - def new(command, args) do - new(command, args, %{}) - end - - def new(command, arg, tags) when not is_list(arg) do - new(command, [arg], tags) - 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(line = %__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?(line = %__MODULE__{args: [nick | _]}, target = %{__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?(line = %__MODULE__{source: %Irc.Mask{nick: nick}}, target = %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 defp parse_args(input), do: parse_args(input, []) @@ -153,18 +118,18 @@ defmodule Irc.Parser.Line do # SOURCE - defp parse_source([?: | input]), do: parse_source(input, []) - defp parse_source(input), do: {nil, input} + defp parse_source([?: | input], m, p), do: parse_source(input, [], m, p) + defp parse_source(input, _, _), do: {nil, input} - defp parse_source([0x20 | rest], acc) do + defp parse_source([0x20 | rest], acc, m, p) do string = acc_to_str(acc) - result = case Irc.Mask.parse(string) do + result = case Irc.Mask.parse(string, m, p) do {:error, _} -> string {:ok, mask} -> mask end {result, rest} end - defp parse_source([char | rest], acc), do: parse_source(rest, [char | acc]) + defp parse_source([char | rest], acc, m, p), do: parse_source(rest, [char | acc], m, p) # TAGS @@ -236,20 +201,4 @@ defmodule Irc.Parser.Line do defp encode_args([], acc), do: acc - defimpl String.Chars, for: __MODULE__ do - def to_string(line) do - Irc.Parser.Line.encode(line) - end - end - - defimpl Inspect, for: __MODULE__ do - @moduledoc false - import Inspect.Algebra - - def inspect(struct, _opts) do - tags = Enum.map(struct.tags, fn({k, v}) -> concat([k, "=", v]) end) |> Enum.join(",") - Enum.join(["#IRC.Line<", struct.source, " ", struct.command, " ", inspect(struct.args), " (", tags, ")>"], "") - end - end - end diff --git a/lib/irc/shout.ex b/lib/irc/shout.ex index 912517c..bcfb10a 100644 --- a/lib/irc/shout.ex +++ b/lib/irc/shout.ex @@ -1,5 +1,5 @@ defmodule Irc.Shout do - alias Irc.{Connection, Parser.Line} + alias Irc.{Connection, Line} def shout(nick, host, target, message, opts \\ []) do opts = opts @@ -44,7 +44,7 @@ defmodule Irc.Shout do def await_join(conn, target) do receive do - {:irc_conn_line, conn, %Irc.Parser.Line{command: "JOIN", args: [target]}} -> :ok + {:irc_conn_line, conn, %Line{command: "JOIN", args: [target]}} -> :ok {:irc_conn_error, conn, reason} -> {:error, reason} {:DOWN, _, _, conn, reason} -> {:error, {:conn_down, reason}} after diff --git a/lib/irc/store.ex b/lib/irc/store.ex new file mode 100644 index 0000000..5aaef6c --- /dev/null +++ b/lib/irc/store.ex @@ -0,0 +1,131 @@ +defmodule Irc.Store do + @moduledoc """ + Mnesia-based store for `Irc.Client`. + + Tables: + * User (transient, in memory): tracks known connected users. + * Channel (transient, in memory): tracks known channels. + + ## Setup + + Needs to be ran only once at setup: + + ```elixir + # First step: create schemas. + Memento.stop() + Memento.Schema.create([node()]) + Memento.start() + + # Second step: create tables + Irc.Store.setup() + ``` + + + """ + + @doc false + # Childrens for Irc.Application supervisor tree + def childs do + [{Irc.Store.OwnerSweeper, []}] + end + + @doc "Creates tables in Mnesia. Should only be run once." + def setup do + Memento.Table.create!(Irc.Store.User) + end + + defmodule OwnerSweeper do + @moduledoc """ + Ensures the Store only keeps alive data by monitoring owners and deleting entries when their owner dies. + """ + use GenServer + require Logger + + @doc "Register calling process to the `OwnerSweeper` process." + def register() do + GenServer.call(__MODULE__, {:monitor, self()}) + end + + @doc "Unregister calling process from the sweeper and delete all its entries." + def unregister() do + GenServer.call(__MODULE__, {:unregister, self()}) + end + + @doc false + def start_link(_) do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + @impl true + @doc false + def init(_) do + {:ok, Map.new} + end + + @impl true + @doc false + def handle_call({:register, pid}, _, monitors) do + if Map.get(monitors, pid) do + {:reply, :ok, monitors} + else + Logger.debug("Irc.Store.OwnerSweeper: added to sweep list #{inspect(pid)}") + monitor = Process.monitor(pid) + {:reply, :ok, Map.put(monitors, pid, monitor)} + end + end + + @impl true + @doc false + def handle_call({:unregister, pid}, _, monitors) do + if ref = Map.get(monitors, pid) do + Process.demonitor(ref, [:flush]) + clear_entries(pid) + {:reply, :ok, Map.drop(monitors, pid)} + else + {:reply, :not_registered, monitors} + end + end + + @impl true + @doc false + def handle_info({:DOWN, monitor, :process, pid, _reason}, monitors) do + Logger.debug("Irc.Store.OwnerSweeper: removing entries of #{inspect(pid)}") + if monitor == Map.get(monitors, pid) do + # TODO: Delete all `User` entries where owner == pid + clear_entries(pid) + {:noreply, Map.drop(monitors, pid)} + else + {:noreply, monitors} + end + end + + defp clear_entries(pid) do + # TODO: Find some way to use built in mnesia select_delete + for table <- [Irc.Store.User] do + Memento.transaction fn -> + for entry <- Memento.Query.select(table, {:==, :owner, pid}) do + Memento.delete(table, entry.id) + end + end + end + end + + end + + + defmodule User do + #attributes = Irc.User.__attrs__ + #|> Enum.map(fn + # ({k, _}) -> k + # k -> k + #end) + + #IO.inspect(attributes) + + #use Memento.Table, + # attributes: attributes, + # index: [:owner, :host, :nick, :account] + + end + +end diff --git a/lib/irc/ts6.ex b/lib/irc/ts6.ex new file mode 100644 index 0000000..710f1a5 --- /dev/null +++ b/lib/irc/ts6.ex @@ -0,0 +1,35 @@ +defmodule Irc.TS6 do + @moduledoc """ + TS6 protocol. Not implemented yet, only basic data structures are defined. + """ + + defmodule Server do + defstruct [ + :name, + :sid, + :description, + {:depth, 1}, + {:users, []}, + {:servers, []}, + :parent_sid + ] + end + + defmodule User do + defstruct [ + :uid, + :name, + :depth, + :ts, + :umode, + :username, + :hostname, + :ip, + :realhost, + :account, + :realname, + :sid + ] + end + +end diff --git a/lib/irc/user.ex b/lib/irc/user.ex index 5e2f526..06a6b97 100644 --- a/lib/irc/user.ex +++ b/lib/irc/user.ex @@ -1,12 +1,19 @@ defmodule Irc.User do + require Record @moduledoc """ Represents an IRC user. Not all field may be returned in events. + + Its main structure is the struct `t()`, but the record `irc_user()` can be used to write queries against an ets collection. """ - defstruct [ + @record :irc_user + + @attrs [ + :id, + :owner, :nick, :user, :host, @@ -20,27 +27,66 @@ defmodule Irc.User do {:modes, []}, {:chanmodes, %{}}, {:channels, []}, + {:assigns, %{}}, + {:private, %{persisted: false}}, ] - @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()} - } + defstruct @attrs + + record_attrs = Enum.map(@attrs, fn + ({_,_}=kv) -> kv + (k) -> {k, :undefined} + end) + keys = :lists.map(&elem(&1, 0), record_attrs) + vals = :lists.map(&{&1, [], nil}, keys) + pairs = :lists.zip(keys, vals) + + Record.defrecord(@record, record_attrs) + +# @type t :: %__MODULE__{ +# id: String.t(), +# owner: nil | pid(), +# 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()}, +## assigns: %{any() => any()} +# } +# @type record :: record(:irc_user) + + @doc false + def __attrs__, do: @attrs + +# @spec to_record(t()) :: t() + def to_record(%__MODULE__{unquote_splicing(pairs)}) do + {@record, unquote_splicing(vals)} + end + +# @spec from_record(t()) :: t() + def from_record({@record, unquote_splicing(vals)}) do + %__MODULE__{unquote_splicing(pairs)} + end + +# @spec from_mask(IRC.Mask.t()) :: 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 +# @spec to_mask(t()) :: IRC.Mask.t() def to_mask(%__MODULE__{nick: nick, user: user, host: host}) do Irc.Mask.new(nick, user, host) end + defimpl Irc.Addressable, for: __MODULE__ do + def nick(%{nick: nick}), do: nick + def user(%{user: user}), do: user + def host(%{host: host}), do: host + end end @@ -13,7 +13,15 @@ defmodule Irc.MixProject do homepage_url: "https://hrefhref.github.io/elixir-irc", docs: [ main: "readme", - extras: ["README.md"] + extras: ["README.md"], + groups_for_modules: [ + "Data": [Irc.Mask, Irc.User, Irc.Line, Irc.Store, ~r/Irc\.Store.*/, Irc.Context, Irc.Addressable], + "Connection": [Irc.Connection, Irc.ConnectionSocket, Irc.STS, ~r/Irc\.Connection*/], + "Clients": [Irc.Shout, Irc.Client, ~r/Irc\.Client.*/], + "Servers": [Irc.Server, Irc.TS6, ~r/Irc\.(Server|TS6).*/], + "Parser": [Irc.Parser, ~r/Irc\.Parser.*/] + ], + nest_modules_by_prefix: [Irc.Parser, Irc.Client, Irc.Client.Command, Irc.Store, Irc.TS6, Irc.Server] ] ] end @@ -31,6 +39,7 @@ defmodule Irc.MixProject do [ {:backoff, github: "ferd/backoff"}, {:ex_doc, "~> 0.21", only: :dev, runtime: false}, + {:memento, "~> 0.3.1"} ] end end @@ -1,8 +1,10 @@ %{ "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"}, + "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.12", "b245e875ec0a311a342320da0551da407d9d2b65d98f7a9597ae078615af3449", [:mix], [], "hexpm", "711e2cc4d64abb7d566d43f54b78f7dc129308a63bc103fbd88550d2174b3160"}, + "ex_doc": {:hex, :ex_doc, "0.23.0", "a069bc9b0bf8efe323ecde8c0d62afc13d308b1fa3d228b65bca5cf8703a529d", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "f5e2c4702468b2fd11b10d39416ddadd2fcdd173ba2a0285ebd92c39827a5a16"}, + "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.15.0", "98312c9f0d3730fde4049985a1105da5155bfe5c11e47bdc7406d88e01e4219b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "75ffa34ab1056b7e24844c90bfc62aaf6f3a37a15faa76b07bc5eba27e4a8b4a"}, + "memento": {:hex, :memento, "0.3.1", "b2909390820550d8b90b68ec96f9e15ff8a45a28b6f97fa4a62ef50e87c2f9d9", [:mix], [], "hexpm", "ff8fc66255d21dcd539c5d77a0b5458715bf3efec91b389dd06017bbb4e2e916"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, } |