diff options
Diffstat (limited to 'lib/irc/client.ex')
-rw-r--r-- | lib/irc/client.ex | 265 |
1 files changed, 243 insertions, 22 deletions
diff --git a/lib/irc/client.ex b/lib/irc/client.ex index 1c4b0c5..d8a7b77 100644 --- a/lib/irc/client.ex +++ b/lib/irc/client.ex @@ -1,48 +1,269 @@ defmodule Irc.Client do - alias Irc.BaseClient - @behaviour Irc.BaseClient + @behaviour :gen_statem require Logger + alias Irc.Parser.Line + alias Irc.Connection + require Irc.Parser.Numeric + import Irc.Parser.Numeric @moduledoc """ - Fully-featured IRC client. + Fully-featured IRC Client. - The client uses message passing. + Events emanating from the client are sent to the owner PID, `t:message/0`, and commands can be sent using `command/3`. The response is asynchronous. + If a command is sent when the client is disconnected, it will be postponed until the client is connected again. + + Known IRC users are cached into `Irc.Client.UserCache`. Depending on the connection capabilities, data returned by the cache may not be up-to-date, and can be retreived with `whois/2`. + + ## Extending + + Most of the commands and capabs are implemented in separate modules. Additional modules can be set in the start options. See `Irc.Client.Capabs`. """ - @type message :: Irc.BaseClient.event - @type start_ret :: BaseClient.start_ret + @commands [ + Irc.Client.Command.Join, + Irc.Client.Command.Who, + Irc.Client.Command.Names, + Irc.Client.Command.Away, + Irc.Client.Command.Chghost, + Irc.Client.Command.Account, + ] + + @internal {:next_event, :internal, nil} + + defstruct [:conn, :conn_mon, :module, :modstate, :args, :nick, :modes, :info, :error, :commands, :buffer] + + def callback_mode, do: [:state_enter] + + @type message :: {:irc_client, pid(), event} + + @type event :: event_connected | event_nick | event_modes | event_line | event_down + | Irc.Client.Command.Join.t + | Irc.Client.Command.Names.t + | Irc.Client.Command.Who.t + | Irc.Client.Command.Away.t + | Irc.Client.Command.Chghost.t + | Irc.Client.Command.AccountNotify.t - @spec start(Irc.Connection.nick, Irc.Connection.host, [BaseClient.start_opt], [:gen_statem.start_opt]) :: start_ret + @type event_connected :: {:connected, Connection.Info.t} + @type event_nick :: {:nick, {prev_nick :: String.t, new_nick :: String.t}} + @type event_modes :: {:modes, {changes :: String.t, prev :: list, modes :: list}} + @type event_line :: {:line, line :: Irc.Parser.Line.t} + @type event_down :: {:down, {Connection.error, delay :: integer}} + + + @type start_opt :: Connection.start_opt + | {:module, module() | Irc.Client} + | {:commands, [module()]} + | {:conn_statem_opts, [:gen_statem.start_opt]} + @type start_ret :: {:ok, pid} | {:error, Connection.error} | :gen_statem.start_ret + + @spec start(Connection.nick, Connection.host, [start_opt], [:gen_statem.start_opt]) :: start_ret def start(nick, host, opts \\ [], start_opts \\ []) do :gen_statem.start(__MODULE__, [nick, host, prepare_args(opts)], start_opts) end - @spec start_link(Irc.Connection.nick, Irc.Connection.host, [BaseClient.start_opt], [:gen_statem.start_opt]) :: start_ret + @spec start_link(Connection.nick, Connection.host, [start_opt], [:gen_statem.start_opt]) :: start_ret def start_link(nick, host, opts \\ [], start_opts \\ []) do :gen_statem.start_link(__MODULE__, [nick, host, prepare_args(opts)], start_opts) end - @doc false - def init(_) do - Logger.debug("CLIENT initiated") - {:ok, %{process: nil}} + def command(pid, command, args) do + ref = make_ref() + Process.send(pid, {:irc_command, command, ref, args}) + ref end - @doc false - def handle_event(event, state) do - Logger.debug("CLIENT EVENT: #{inspect event}") - :ok + @doc "Whois an user. Depending on the connection capabilities and cache contents, a `WHOIS` irc command may not be executed; unless `cached: false`." + @type whois_opt :: {:cached, true | false} + @spec whois(pid(), String.t, Keyword.t) :: Irc.User.t() + def whois(pid, nick, opts \\ []) do + end + + def call_command(pid, command, args, opts \\ []) do + timeout = Keyword.get(opts, :timeout, 10_000) + ref = command(pid, command, args) + receive do + {:irc_command, command, result} -> result + after + timeout -> {:error, :timeout} + end end @doc false - def stop(reason, state) do - Logger.debug("CLIENT: Stopping, #{inspect reason}") + def init([args]) do + with \ + commands <- init_commands(args), + capabs <- Enum.uniq(commands.capabs ++ Keyword.get(args, :capabs, [])), + args <- Keyword.put(args, :capabs, capabs), + {:ok, conn} <- Connection.start(Keyword.get(args, :nick), Keyword.get(args, :host), args, Keyword.get(args, :conn_statem_opts, [])), + conn_mon <- Process.monitor(conn) + do + Logger.info("Commands registered: #{inspect commands}") + data = %__MODULE__{ + conn: conn, conn_mon: conn_mon, + args: args, + commands: commands + } + {:ok, :disconnected, data} + else + error -> {:stop, error} + end end - defp prepare_args(opts) do - opts - |> Keyword.put_new(:module, __MODULE__) + def handle_event(:enter, :connected, :disconnected, data) do + Logger.debug "#{inspect data} disconnected: #{inspect data.error}" + :keep_state_and_data end -end + def handle_event(:info, msg = {:irc_conn_up, _, info}, :disconnected, data) do + data = run_handler_event({:connected, info}, data) + {:next_state, :connected, %__MODULE__{data | nick: info.nick, info: info, modes: info.modes, error: nil}} + end + + # + # --- Connected + # + + def handle_event(:enter, _, :connected, data) do + Logger.debug "#{inspect data} UP" + :keep_state_and_data + end + + def handle_event(:info, msg = {:irc_conn_nick, _, prev_nick, new_nick}, :connected, data) do + data = run_handler_event({:nick, prev_nick, new_nick}, data) + {:keep_state, %__MODULE__{data | nick: new_nick}} + end + + def handle_event(:info, msg = {:irc_conn_modes, _, changes, prev, modes}, :connected, data) do + data = run_handler_event({:modes, changes, prev, modes}, data) + {:keep_state, %__MODULE__{data | modes: modes}} + end + + def handle_event(:info, {:irc_conn_down, _, reason, delay}, :connected, data) do + data = run_handler_event({:down, reason, delay}, data) + {:next_state, :disconnected, %__MODULE__{data | error: reason}} + end + + def handle_event(:info, {:irc_command, command, args}, :connected, data) do + if module = get_in(data, [:commands, :user, command]) do + {next, actions} = case module.handle_command(command, args, data.info) do + {next, next_arg} -> {{next, next_arg}, []} + {next, next_arg, actions} -> {{next, next_arg}, List.wrap(actions)} + next when is_atom(next) -> {{next, nil}, []} + end + + data = handle_actions(actions, data) + + case next do + {:buffer, acc} -> {:next_state, {:buffer, module}, %__MODULE__{data | buffer: acc}} + end + else + data = run_handler_event({:irc_command, command, {:error, :unsupported_command, command}}, data) # MEH? + {:keep_state, data} + end + end + + # DOES NOT HANDLE ERROR (actions can just return data now) + defp handle_actions([{:send, line} | rest], data) do + case Connection.sendline(data.conn, line) do + :ok -> handle_actions(rest, data) + error -> {:next_state, :disconnected, %__MODULE__{data | error: error}} + end + data + end + defp handle_actions([{:event, event} | rest], data) do + run_handler_event(event, data) + end + defp handle_actions([], data), do: data + + # TODO: REMOVE? + def handle_event(:info, {:irc_conn_line, _, line}, :connected, data) do + data = run_handler_event({:line, line}, data) + :keep_state_and_data + end + + # + # --- Buffer + # + + def handle_event(:info, {:irc_conn_line, _, line}, {:buffer, module}, data) do + {next, actions} = case module.handle_buffer(line, data.buffer, data.info) do + {next, next_arg} -> {{next, next_arg}, []} + {next, next_arg, actions} -> {{next, next_arg}, List.wrap(actions)} + next when is_atom(next) -> {{next, nil}, []} + end + + data = handle_actions(actions, data) + + case next do + {:finish, _} -> {:next_state, :connected, %__MODULE__{data | buffer: nil}} + {:buffer, acc} -> {:keep_state, %__MODULE__{data | buffer: acc}} + end + end + + # TODO: Callback stop? + def handle_event(:internal, reason, :error, data) do + Logger.error "#{inspect data}: #{inspect reason}" + data.module.stop(reason, data.modstate) + {:stop, :normal} + end + + def handle_event(:info, {:irc_conn_error, _, reason}, _, data) do + {:next_state, :error, data, {:next_event, :internal, reason}} + end + + def handle_event(:enter, _, _, _) do + :keep_state_and_data + end + + def terminate(reason, state, data) do + Logger.error("#{inspect(data)} terminating in #{inspect(state)}: #{inspect reason}") + end + + def code_change(_old_vsn, old_state, old_data, _extra) do + {:ok, old_state, old_data} + end + + defp prepare_args(args) do + args + end + + defp run_handler_event(event, data) do + case data.module.handle_event(event, data.modstate) do + {:ok, modstate} -> %__MODULE__{data | modstate: modstate} + :ok -> data + end + end + + defp init_commands(args) do + commands = @commands ++ Keyword.get(args, :commands, []) + Enum.reduce(commands, %{server: %{}, user: %{}, capabs: []}, fn(module, acc) -> + {server_commands, user_commands, capabs} = module.init() + + get_value = fn(option) -> + case option do + str when is_binary(str) -> [str] + nil -> [] + list when is_list(list) -> list + end + end + + reduce_fun = fn(command, acc) -> Map.put(acc, command, module) end + + server = Enum.reduce(List.wrap(server_commands), acc.server, reduce_fun) + user = Enum.reduce(List.wrap(user_commands), acc.user, reduce_fun) + capabs = List.concat(acc.capabs, List.wrap(capabs)) + %{server: server, user: user, capabs: capabs} + end) + end + + defimpl Inspect, for: __MODULE__ do + import Inspect.Algebra + def inspect(struct, _opts) do + concat(["#Irc.Client<", inspect(self()), ">"]) + end + end + + +end |