defmodule Irc.Client do @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. 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`. """ @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 @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(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 def command(pid, command, args) do ref = make_ref() Process.send(pid, {:irc_command, command, ref, args}) ref end @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 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 def handle_event(:enter, :connected, :disconnected, data) do Logger.debug "#{inspect data} disconnected: #{inspect data.error}" :keep_state_and_data 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