diff --git a/lib/exirc/client.ex b/lib/exirc/client.ex index 63bc572..95d1829 100644 --- a/lib/exirc/client.ex +++ b/lib/exirc/client.ex @@ -1,381 +1,381 @@ defmodule ExIrc.Client do @moduledoc """ Maintains the state and behaviour for individual IRC client connections """ import Irc.Commands import ExIrc.Logger alias ExIrc.Channels, as: Channels alias ExIrc.Utils, as: Utils # Records defrecord ClientState, event_handlers: [], server: 'localhost', port: 6667, socket: nil, nick: '', pass: '', user: '', name: '', logged_on?: false, autoping: true, channel_prefixes: '', network: '', user_prefixes: '', login_time: '', channels: [], debug: false defrecord IrcMessage, server: '', nick: '', user: '', host: '', ctcp: nil, cmd: '', args: [] ################# # Module API ################# def start!(options // []) do start_link(options) end def start_link(options // []) do :gen_server.start_link(__MODULE__, options, []) end def stop!(client) do :gen_server.call(client, :stop) end def connect!(client, server, port) do :gen_server.call(client, {:connect, server, port}, :infinity) end def logon(client, pass, nick, user, name) do :gen_server.call(client, {:logon, pass, nick, user, name}, :infinity) end def msg(client, type, nick, msg) do :gen_server.call(client, {:msg, type, nick, msg}, :infinity) end def nick(client, new_nick) do :gen_server.call(client, {:nick, new_nick}, :infinity) end def cmd(client, raw_cmd) do :gen_server.call(client, {:cmd, raw_cmd}) end def join(client, channel, key) do :gen_server.call(client, {:join, channel, key}, :infinity) end def part(client, channel) do :gen_server.call(client, {:part, channel}, :infinity) end def quit(client, msg // 'Leaving..') do :gen_server.call(client, {:quit, msg}, :infinity) end def is_logged_on?(client) do :gen_server.call(client, :is_logged_on?) end def channels(client) do :gen_server.call(client, :channels) end def channel_users(client, channel) do :gen_server.call(client, {:channel_users, channel}) end def channel_topic(client, channel) do :gen_server.call(client, {:channel_topic, channel}) end def channel_type(client, channel) do :gen_server.call(client, {:channel_type, channel}) end def channel_has_user?(client, channel, nick) do :gen_server.call(client, {:channel_has_user?, channel, nick}) end def add_handler(client, pid) do :gen_server.call(client, {:add_handler, pid}) end def remove_handler(client, pid) do :gen_server.call(client, {:remove_handler, pid}) end def add_handler_async(client, pid) do :gen_server.cast(client, {:add_handler, pid}) end def remove_handler_async(client, pid) do :gen_server.cast(client, {:remove_handler, pid}) end def state(client) do :gen_server.call(client, :state) end ############### # GenServer API ############### def init(options // []) do autoping = Keyword.get(options, :autoping, true) debug = Keyword.get(options, :debug, false) # Add event handlers handlers = Keyword.get(options, :event_handlers, []) |> Enum.foldl(&do_add_handler/2) # Return initial state {:ok, ClientState.new( event_handlers: handlers, autoping: autoping, logged_on?: false, debug: debug, channels: ExIrc.Channels.init())} end def handle_call({:add_handler, pid}, _from, state) do handlers = do_add_handler(pid, state.event_handlers) {:reply, :ok, state.event_handlers(handlers)} end def handle_call({:remove_handler, pid}, _from, state) do handlers = do_remove_handler(pid, state.event_handlers) {:reply, :ok, state.event_handlers(handlers)} end def handle_call(:state, _from, state), do: {:reply, state, state} def handle_call(:stop, _from, state), do: {:stop, :normal, :ok, state} def handle_call({:connect, server, port}, _from, state) do case :gen_tcp.connect(server, port, [:list, {:packet, :line}]) do {:ok, socket} -> send_event {:connect, server, port}, state {:reply, :ok, state.server(server).port(port).socket(socket)} error -> {:reply, error, state} end end def handle_call({:logon, pass, nick, user, name}, _from, ClientState[logged_on?: false] = state) do send! state.socket, pass!(pass) send! state.socket, nick!(nick) send! state.socket, user!(user, name) send_event({:login, pass, nick, user, name}, state) {:reply, :ok, state.pass(pass).nick(nick).user(user).name(name)} end def handle_call(:is_logged_on?, _from, state), do: {:reply, state.is_logged_on?, state} def handle_call(_, _from, ClientState[logged_on?: false] = state), do: {:reply, {:error, :not_connected}, state} def handle_call({:msg, type, nick, msg}, _from, state) do data = case type do :privmsg -> privmsg!(nick, msg) :notice -> notice!(nick, msg) :ctcp -> notice!(nick, ctcp!(msg)) end send! state.stocket, data {:reply, :ok, state} end def handle_call({:quit, msg}, _from, state), do: send!(state.socket, quit!(msg)) and {:reply, :ok, state} def handle_call({:join, channel, key}, _from, state), do: send!(state.socket, join!(channel, key)) and {:reply, :ok, state} def handle_call({:part, channel}, _from, state), do: send!(state.socket, part!(channel)) and {:reply, :ok, state} def handle_call({:nick, new_nick}, _from, state), do: send!(state.socket, nick!(new_nick)) and {:reply, :ok, state} def handle_call({:cmd, raw_cmd}, _from, state), do: send!(state.socket, command!(raw_cmd)) and {:reply, :ok, state} def handle_call(:channels, _from, state), do: {:reply, Channels.channels(state.channels), state} def handle_call({:channel_users, channel}, _from, state), do: {:reply, Channels.channel_users(state.channels, channel), state} def handle_call({:channel_topic, channel}, _from, state), do: {:reply, Channels.channel_topic(state.channels, channel), state} def handle_call({:channel_type, channel}, _from, state), do: {:reply, Channels.channel_type(state.channels, channel), state} def handle_call({:channel_has_user?, channel, nick}, _from, state) do {:reply, Channels.channel_has_user?(state.channels, channel, nick), state} end def handle_cast({:add_handler, pid}, state) do handlers = do_add_handler(pid, state.event_handlers) {:noreply, state.event_handlers(handlers)} end def handle_cast({:remove_handler, pid}, state) do handlers = do_remove_handler(pid, state.event_handlers) {:noreply, state.event_handlers(handlers)} end - def handle_info({:tcp_closed, _socket}, ClientState[server: server, port: port, channels: channels]) do + def handle_info({:tcp_closed, _socket}, ClientState[server: server, port: port] = state) do info "Connection to #{server}:#{port} closed!" - {:noreply, channels(Channels.init())} + {:noreply, state.channels(Channels.init())} end def handle_info({:tcp_error, socket}, state) do {:stop, {:tcp_error, socket}, state} end def handle_info({:tcp, _, data}, state) do debug? = state.debug case Utils.parse(data) do IrcMessage[ctcp: true] = msg -> send_event(msg, state) {:noreply, state} IrcMessage[ctcp: false] = msg -> send_event(msg, state) handle_data(msg, state) IrcMessage[ctcp: :invalid] = msg when debug? -> send_event(msg, state) {:noreply, state} _ -> {:noreply, state} end end def handle_info({'DOWN', _, _, pid, _}, state) do handlers = do_remove_handler(pid, state.event_handlers) {:noreply, state.event_handlers(handlers)} end def handle_info(_, state) do {:noreply, state} end # Handle termination def terminate(_reason, _state), do: :ok # Handle code changes def code_change(_old, state, _extra), do: {:ok, state} ############### # Data handling ############### # Sucessfully logged in def handle_data(IrcMessage[cmd: @rpl_WELCOME] = _msg, ClientState[logged_on?: false] = state) do {:noreply, state.logged_on?(true).login_time(:erlang.now())} end # Server capabilities def handle_data(IrcMessage[cmd: @rpl_ISUPPORT] = msg, state) do {:noreply, Utils.isup(msg.args, state)} end # Client entered a channel def handle_data(IrcMessage[nick: nick, cmd: 'JOIN'] = msg, ClientState[nick: nick] = state) do channels = Channels.join(state.channels, Enum.first(msg.args)) {:noreply, state.channels(channels)} end # Someone joined the client's channel def handle_data(IrcMessage[nick: user_nick, cmd: 'JOIN'] = msg, state) do channels = Channels.user_join(state.channels, Enum.first(msg.args), user_nick) {:noreply, state.channels(channels)} end # Topic message on join # 3 arguments is not RFC compliant but _very_ common # 2 arguments is RFC compliant def handle_data(IrcMessage[cmd: @rpl_TOPIC] = msg, state) do {channel, topic} = case msg.args do [_nick, channel, topic] -> {channel, topic} [channel, topic] -> {channel, topic} end channels = Channels.set_topic(state.channels, channel, topic) {:noreply, state.channels(channels)} end # Topic message while in channel def handle_data(IrcMessage[cmd: 'TOPIC', args: [channel, topic]], state) do channels = Channels.set_topic(state.channels, channel, topic) {:noreply, state.channels(channels)} end # NAMES reply - def handle_data(IrcMessage[cmd: @rpl_NAMREPLY] = msg, state) do + def handle_data(IrcMessage[cmd: @rpl_NAMEREPLY] = msg, state) do {channel_type, channel, names} = case msg.args do [_nick, channel_type, channel, names] -> {channel_type, channel, names} [channel_type, channel, names] -> {channel_type, channel, names} end channels = Channels.set_type( Channels.users_join(state.channels, channel, String.split(names, ' '), channel, channel_type)) {:noreply, state.channels(channels)} end # We successfully changed name def handle_data(IrcMessage[cmd: 'NICK', nick: nick, args: [new_nick]], ClientState[nick: nick] = state) do {:noreply, state.nick(new_nick)} end # Someone we know (or can see) changed name def handle_data(IrcMessage[cmd: 'NICK', nick: nick, args: [new_nick]], state) do channels = Channels.user_rename(state.channels, nick, new_nick) {:noreply, state.channels(channels)} end # We left a channel def handle_data(IrcMessage[cmd: 'PART', nick: nick] = msg, ClientState[nick: nick] = state) do channels = Channels.part(state.channels, Enum.first(msg.args)) {:noreply, state.channels(channels)} end # Someone left a channel we are in def handle_data(IrcMessage[cmd: 'PART', nick: user_nick] = msg, state) do channels = Channels.user_part(state.channels, Enum.first(msg.args), user_nick) {:noreply, state.channels(channels)} end # We got a ping, reply if autoping is on. def handle_data(IrcMessage[cmd: 'PING'] = msg, ClientState[autoping: true] = state) do case msg do IrcMessage[args: [from]] -> send!(state.socket, pong2!(state.nick, from)) _ -> send!(state.socket, pong1!(state.nick)) end {:noreply, state}; end # "catch-all" (probably should remove this) def handle_data(_msg, state) do {:noreply, state} end ############### # Internal API ############### def send_event(msg, ClientState[event_handlers: handlers]) when is_list(handlers) do Enum.each(handlers, fn({pid, _}) -> pid <- msg end) end def gv(key, options), do: :proplists.get_value(key, options) def gv(key, options, default), do: :proplists.get_value(key, options, default) def do_add_handler(pid, handlers) do case Process.alive?(pid) and not Enum.member(handlers, pid) do true -> ref = Process.monitor(pid) [{pid, ref} | handlers] false -> handlers end end def do_remove_handler(pid, handlers) do case List.keyfind(handlers, pid, 1) do {pid, ref} -> Process.demonitor(ref) List.keydelete(handlers, pid, 1) false -> handlers end end end \ No newline at end of file diff --git a/lib/exirc/commands.ex b/lib/exirc/commands.ex index 3f78d4d..ded3704 100644 --- a/lib/exirc/commands.ex +++ b/lib/exirc/commands.ex @@ -1,87 +1,87 @@ defmodule Irc.Commands do # Helpers @crlf '\r\n' defmacro command!(cmd) do quote do: [unquote(cmd), @crlf] end defmacro ctcp!(cmd) do quote do: [1, unquote(cmd), 1] end defmacro send!(socket, data) do quote do: :gen_tcp.send(unquote(socket), unquote(data)) end # IRC Commands defmacro pass!(pwd) do quote do: command! ['PASS ', unquote(pwd)] end defmacro nick!(nick) do quote do: command! ['NICK ', unquote(nick)] end defmacro user!(user, name) do quote do: command! ['USER ', unquote(user), ' 0 * :', unquote(name)] end defmacro pong1!(nick) do quote do: command! ['PONG ', unquote(nick)] end defmacro pong2!(nick, to) do quote do: command! ['PONG ', unquote(nick), ' ', unquote(to)] end defmacro privmsg!(nick, msg) do quote do: command! ['PRIVMSG ', unquote(nick), ' :', unquote(msg)] end defmacro notice!(nick, msg) do quote do: command! ['NOTICE ', unquote(nick), ' :', unquote(msg)] end defmacro join!(channel, key) do quote do: command! ['JOIN ', unquote(channel), ' ', unquote(key)] end defmacro part!(channel) do quote do: command! ['PART ', unquote(channel)] end defmacro quit!(msg // 'Leaving') do quote do: command! ['QUITE :', unquote(msg)] end #################### # IRC Numeric Codes #################### @rpl_WELCOME '001' @rpl_YOURHOST '002' @rpl_CREATED '003' @rpl_MYINFO '004' # @rpl_BOUNCE '005' # RFC2812 @rpl_ISUPPORT '005' # Defacto standard for server support @rpl_BOUNCE '010' # Defacto replacement of '005' in RFC2812 @rpl_STATSDLINE '250' @rpl_LUSERCLIENT '251' @rpl_LUSEROP '252' @rpl_LUSERUNKNOWN '253' @rpl_LUSERCHANNELS '254' @rpl_LUSERME '255' @rpl_LOCALUSERS '265' @rpl_GLOBALUSERS '266' @rpl_TOPIC '332' - @rpl_NAMREPLY '353' + @rpl_NAMEREPLY '353' @rpl_ENDOFNAMES '366' @rpl_MOTD '372' @rpl_MOTDSTART '375' @rpl_ENDOFMOTD '376' # Error Codes @err_NONICKNAMEGIVEN '431' @err_ERRONEUSNICKNAME '432' @err_NICKNAMEINUSE '433' @err_NICKCOLLISION '436' @err_UNAVAILRESOURCE '437' @err_NEEDMOREPARAMS '461' @err_ALREADYREGISTRED '462' @err_RESTRICTED '484' # Code groups @logon_errors [@err_NONICKNAMEGIVEN, @err_ERRONEUSNICKNAME, @err_NICKNAMEINUSE, @err_NICKCOLLISION, @err_UNAVAILRESOURCE, @err_NEEDMOREPARAMS, @err_ALREADYREGISTRED, @err_RESTRICTED] end \ No newline at end of file diff --git a/lib/exirc/utils.ex b/lib/exirc/utils.ex index bdc283a..f480e1f 100644 --- a/lib/exirc/utils.ex +++ b/lib/exirc/utils.ex @@ -1,115 +1,120 @@ defmodule ExIrc.Utils do alias ExIrc.Client.IrcMessage, as: IrcMessage @doc """ - Parse IRC message data + Parse an IRC message """ def parse(raw_data) do - data = Enum.slice(raw_data, 1, Enum.count(raw_data) - 2) - case data do - [?:, _] -> - [[?: | from] | rest] = :string.tokens(data, ' ') - get_cmd rest, parse_from(from, IrcMessage.new(ctcp: false)) - data -> - get_cmd :string.tokens(data, ' '), IrcMessage.new(ctcp: false) - end + [[?: | from] | rest] = :string.tokens(raw_data, ' ') + get_cmd rest, parse_from(from, IrcMessage.new(ctcp: false)) end - def parse_from(from, msg) do + defp parse_from(from, msg) do case Regex.split(%r/(!|@|\.)/, from) do [nick, '!', user, '@', host | host_rest] -> msg.nick(nick).user(user).host(host ++ host_rest) [nick, '@', host | host_rest] -> msg.nick(nick).host(host ++ host_rest) [_, '.' | _] -> # from is probably a server name msg.server(from) [nick] -> msg.nick(nick) end end - def get_cmd([cmd, arg1, [':', 1 | ctcp_trail] | rest], msg) when cmd == 'PRIVMSG' or cmd == 'NOTICE' do - get_cmd([cmd, arg1, [1 | ctcp_trail] | rest], msg) + # Parse command from message + defp get_cmd([cmd, arg1, [?:, 1 | ctcp_trail] | restargs], msg) when cmd == 'PRIVMSG' or cmd == 'NOTICE' do + get_cmd([cmd, arg1, [1 | ctcp_trail] | restargs], msg) end - def get_cmd([cmd, _arg1, [1 | ctcp_trail] | rest], msg) when cmd == 'PRIVMSG' or cmd == 'NOTICE' do - list = (ctcp_trail ++ (lc arg inlist rest, do: ' ' ++ arg)) - |> Enum.flatten - |> Enum.reverse - case list do + + defp get_cmd([cmd, _arg1, [1 | ctcp_trail] | restargs], msg) when cmd == 'PRIVMSG' or cmd == 'NOTICE' do + args = ctcp_trail ++ lc arg inlist restargs, do: ' ' ++ arg + |> Enum.flatten + |> Enum.reverse + case args do [1 | ctcp_rev] -> - [ctcp_cmd | args] = Enum.reverse(ctcp_rev) |> String.split(' ') - msg = msg.cmd(ctcp_cmd).args(args).ctcp(true) + [ctcp_cmd | args] = ctcp_rev |> Enum.reverse |> :string.tokens(' ') + msg.cmd(ctcp_cmd).args(args).ctcp(true) _ -> - msg = msg.cmd(cmd).ctcp(:invalid) + msg.cmd(cmd).ctcp(:invalid) end end - def get_cmd([cmd | rest], msg) do + + defp get_cmd([cmd | rest], msg) do get_args(rest, msg.cmd(cmd)) end - def get_args([], msg) do - msg.args(Enum.reverse(msg.args)) + + # Parse command args from message + defp get_args([], msg) do + msg.args + |> Enum.reverse + |> Enum.filter(fn(arg) -> arg != [] end) + |> msg.args end - def get_args([[':' | first_arg] | rest], msg) do - list = lc arg inlist [first_arg | rest], do: ' ' ++ arg - case Enum.flatten(list) do + + defp get_args([[':' | first_arg] | rest], msg) do + args = lc arg inlist [first_arg | rest], do: ' ' ++ arg |> Enum.flatten + case args do [_ | []] -> - get_args([], msg.args(['' | msg.args])) + get_args([], msg.args([msg.args])) [_ | full_trail] -> get_args([], msg.args([full_trail | msg.args])) end end - def get_args([arg | []], msg) do - get_args([], msg.args(['', arg | msg.args])) + + defp get_args([arg | []], msg) do + get_args([], msg.args([arg | msg.args])) end - def get_args([arg | rest], msg) do + + defp get_args([arg | rest], msg) do get_args(rest, msg.args([arg | msg.args])) end ########################## # Parse RPL_ISUPPORT (005) ########################## def isup([], state), do: state def isup([param | rest], state) do try do isup(rest, isup_param(param, state)) rescue _ -> isup(rest, state) end end def isup_param('CHANTYPES=' ++ channel_prefixes, state) do state.channel_prefixes(channel_prefixes) end def isup_param('NETWORK=' ++ network, state) do state.network(network) end def isup_param('PREFIX=' ++ user_prefixes, state) do prefixes = Regex.run(%r/\((.*)\)(.*)/, user_prefixes, capture: :all_but_first) |> List.zip state.user_prefixes(prefixes) end def isup_param(_, state) do state end @days_of_week ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'] @months_of_year ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'] def ctcp_time({{y, m, d}, {h, n, s}}) do [:lists.nth(:calendar.day_of_the_week(y,m,d), @days_of_week), ' ', :lists.nth(m, @months_of_year), ' ', :io_lib.format("~2..0s", [integer_to_list(d)]), ' ', :io_lib.format("~2..0s", [integer_to_list(h)]), ':', :io_lib.format("~2..0s", [integer_to_list(n)]), ':', :io_lib.format("~2..0s", [integer_to_list(s)]), ' ', integer_to_list(y)] |> List.flatten end end \ No newline at end of file diff --git a/test/utils_test.exs b/test/utils_test.exs index 3086f7d..cfd7cfb 100644 --- a/test/utils_test.exs +++ b/test/utils_test.exs @@ -1,17 +1,16 @@ defmodule ExIrc.UtilsTest do use ExUnit.Case alias ExIrc.Utils, as: Utils alias ExIrc.Client.IrcMessage, as: IrcMessage - alias ExIrc.Client.ClientState, as: ClientState test "Given a local date/time as a tuple, can retrieve get the CTCP formatted time" do local_time = {{2013,12,6},{14,5,00}} assert Utils.ctcp_time(local_time) == 'Fri Dec 06 14:05:00 2013' end test "Can parse an IRC message" do message = ':irc.example.org 005 nick PREFIX=(ov)@+ CHANTYPES=#&' assert IrcMessage[server: 'irc.example.org', cmd: '005', args: ['nick', 'PREFIX=(ov)@+', 'CHANTYPES=#&']] = Utils.parse(message) end end