diff --git a/lib/exirc/client.ex b/lib/exirc/client.ex index 95d1829..6e6e71a 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 + use 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] = state) do info "Connection to #{server}:#{port} closed!" {: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 + 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 + 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 + 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_NAMEREPLY] = 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 ded3704..dcd7a5f 100644 --- a/lib/exirc/commands.ex +++ b/lib/exirc/commands.ex @@ -1,87 +1,206 @@ defmodule Irc.Commands do + defmacro __using__(_) do + + quote do + import Irc.Commands + + #################### + # IRC Numeric Codes + #################### + + @rpl_welcome '001' + @rpl_yourhost '002' + @rpl_created '003' + @rpl_myinfo '004' + @rpl_isupport '005' # Defacto standard for server support + @rpl_bounce '010' # Defacto replacement of '005' in RFC2812 + @rpl_statsdline '250' + #@doc """ + #":There are users and invisible on servers" + #""" + @rpl_luserclient '251' + #@doc """ + # " :operator(s) online" + #""" + @rpl_luserop '252' + #@doc """ + #" :unknown connection(s)" + #""" + @rpl_luserunknown '253' + #@doc """ + #" :channels formed" + #""" + @rpl_luserchannels '254' + #@doc """ + #":I have clients and servers" + #""" + @rpl_luserme '255' + #@doc """ + #Local/Global user stats + #""" + @rpl_localusers '265' + @rpl_globalusers '266' + #@doc """ + #When sending a TOPIC message to determine the channel topic, + #one of two replies is sent. If the topic is set, RPL_TOPIC is sent back else + #RPL_NOTOPIC. + #""" + @rpl_notopic '331' + @rpl_topic '332' + #@doc """ + #To reply to a NAMES message, a reply pair consisting + #of RPL_NAMREPLY and RPL_ENDOFNAMES is sent by the + #server back to the client. If there is no channel + #found as in the query, then only RPL_ENDOFNAMES is + #returned. The exception to this is when a NAMES + #message is sent with no parameters and all visible + #channels and contents are sent back in a series of + #RPL_NAMEREPLY messages with a RPL_ENDOFNAMES to mark + #the end. + + #Format: " :[[@|+] [[@|+] [...]]]" + #""" + @rpl_namereply '353' + @rpl_endofnames '366' + #@doc """ + #When responding to the MOTD message and the MOTD file + #is found, the file is displayed line by line, with + #each line no longer than 80 characters, using + #RPL_MOTD format replies. These should be surrounded + #by a RPL_MOTDSTART (before the RPL_MOTDs) and an + #RPL_ENDOFMOTD (after). + #""" + @rpl_motd '372' + @rpl_motdstart '375' + @rpl_endofmotd '376' + + ################ + # Error Codes + ################ + + #@doc """ + #Used to indicate the nickname parameter supplied to a command is currently unused. + #""" + @err_no_such_nick '401' + #@doc """ + #Used to indicate the server name given currently doesn't exist. + #""" + @err_no_such_server '402' + #@doc """ + #Used to indicate the given channel name is invalid. + #""" + @err_no_such_channel '403' + #@doc """ + #Sent to a user who is either (a) not on a channel which is mode +n or (b), + #not a chanop (or mode +v) on a channel which has mode +m set, and is trying + #to send a PRIVMSG message to that channel. + #""" + @err_cannot_send_to_chan '404' + #@doc """ + #Sent to a user when they have joined the maximum number of allowed channels + #and they try to join another channel. + #""" + @err_too_many_channels '405' + #@doc """ + #Returned to a registered client to indicate that the command sent is unknown by the server. + #""" + @err_unknown_command '421' + #@doc """ + #Returned when a nickname parameter expected for a command and isn't found. + #""" + @err_no_nickname_given '431' + #@doc """ + #Returned after receiving a NICK message which contains characters which do not fall in the defined set. + #""" + @err_erroneus_nickname '432' + #@doc """ + #Returned when a NICK message is processed that results in an attempt to + #change to a currently existing nickname. + #""" + @err_nickname_in_use '433' + #@doc """ + #Returned by a server to a client when it detects a nickname collision + #(registered of a NICK that already exists by another server). + #""" + @err_nick_collision '436' + #@doc """ + #""" + @err_unavail_resource '437' + #@doc """ + #Returned by the server to indicate that the client must be registered before + #the server will allow it to be parsed in detail. + #""" + @err_not_registered '451' + #""" + # Returned by the server by numerous commands to indicate to the client that + # it didn't supply enough parameters. + #""" + @err_need_more_params '461' + #@doc """ + #Returned by the server to any link which tries to change part of the registered + #details (such as password or user details from second USER message). + #""" + @err_already_registered '462' + #@doc """ + #Returned by the server to the client when the issued command is restricted + #""" + @err_restricted '484' + + ############### + # Code groups + ############### + + @logon_errors [ unquote(@err_no_nickname_given), unquote(@err_erroneus_nickname), + unquote(@err_nickname_in_use), unquote(@err_nick_collision), + unquote(@err_unavail_resource), unquote(@err_need_more_params), + unquote(@err_already_registered), unquote(@err_restricted) ] + end + + end + # 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)] + quote do: command! ['QUIT :', 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_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