diff --git a/lib/exirc/client.ex b/lib/exirc/client.ex new file mode 100644 index 0000000..341fcc0 --- /dev/null +++ b/lib/exirc/client.ex @@ -0,0 +1,403 @@ +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 = '', + bot_supervisor = nil, + 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 install_bot(client, botid, module, args) do + :gen_server.call(client, {:install_bot, botid, module, args}, :infinity) + end + + def uninstall_bot(client, botid) do + :gen_server.call(client, {:uninstall_bot, botid}, :infinity) + 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) + bots = Keyword.get(options, :bots, []) + # Start bot supervisor and children + {:ok, botsup} = ExIrc.Bots.start!(bots) + # 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(), + bot_supervisor: botsup)} + end + + + def handle_call({:install_bot, botid, module, args}, _from, state) do + start_bot(state.bot_supervisor, {botid, module, args}) + {:reply, :ok, state} + end + + def handle_call({:uninstall_bot, botid}, _from, state) do + stop_bot(state.bot_supervisor, botid) + {:reply, :ok, state} + 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, state) when not state.logged_on? 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, state) when not state.is_logged_on?, 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 + :gen_tcp.send(state.socket, data) + {:reply, :ok, state} + end + + def handle_call({:quit, msg}, _from, state), do: send!(state.socket, QUIT(msg)); {:reply, :ok, state} + def handle_call({:join, channel, key}, _from, state), do: send!(state.socket, JOIN(channel, key)); {:reply, :ok, state} + def handle_call({:part, channel}, _from, state), do: send!(state.socket, PART(channel)); {:reply, :ok, state} + def handle_call({:nick, new_nick}, _from, state), do: send!(state.socket, NICK(new_nick)); {:reply, :ok, state} + def handle_call({:cmd, raw_cmd}, _from, state), do: send!(state.socket, CMD(raw_cmd)); {: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}, state) do + notice "Connection 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 + 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 state.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(msg, state) when msg.cmd == @RPL_WELCOME and not state.logged_on? do + {:noreply, state[logged_on?: true, login_time: :erlang.now()]} + end + + # Server capabilities + def handle_data(msg, state) when msg.cmd == @RPL_ISUPPORT 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 + {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) -> :proplists.get_value(key, options) + def gv(key, options, default) -> :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 new file mode 100644 index 0000000..5566ac7 --- /dev/null +++ b/lib/exirc/commands.ex @@ -0,0 +1,87 @@ +defmodule Irc.Commands do + + # Helpers + @CRLF '\r\n' + defmacro CMD(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: CMD(['PASS ', unquote(pwd)]) + end + defmacro NICK(nick) do + quote do: CMD ['NICK ', unquote(nick)] + end + defmacro USER(user, name) do + quote do: CMD ['USER ', unquote(user), ' 0 * :', unquote(name)] + end + defmacro PONG1(nick) do + quote do: CMD ['PONG ', unquote(nick)] + end + defmacro PONG2(nick, to) do + quote do: CMD ['PONG ', unquote(nick), ' ', unquote(to)] + end + defmacro PRIVMSG(nick, msg) do + quote do: CMD ['PRIVMSG ', unquote(nick), ' :', unquote(msg)] + end + defmacro NOTICE(nick, msg) do + quote do: CMD ['NOTICE ', unquote(nick), ' :', unquote(msg)] + end + defmacro JOIN(channel, key) do + quote do: CMD ['JOIN ', unquote(channel), ' ', unquote(key)] + end + defmacro PART(channel) do + quote do: CMD ['PART ', unquote(channel)] + end + defmacro QUIT(msg // 'Leaving') do + quote do: CMD ['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_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/exirc_client.ex b/lib/exirc/exirc_client.ex deleted file mode 100644 index d0d8f58..0000000 --- a/lib/exirc/exirc_client.ex +++ /dev/null @@ -1,93 +0,0 @@ -defmodule ExIrc.Client do - use GenServer.Behaviour - import Logger - - # Maintains client state - defrecord ClientState, events: nil, socket: nil - # Defines the connection to an IRC server - defrecord IrcConnection, host: 'localhost', port: 6667, password: '' - - ##################### - # Public API - ##################### - - @doc """ - Add a new event handler (i.e bot) to a client - """ - def add_handler(client, handler, args // []) do - :gen_server.cast(client, {:add_handler, handler, args}) - end - - @doc """ - Connect a client to the provided IRC server - """ - def connect!(client, connection) do - :gen_server.cast client, {:connect, connection} - end - - @doc """ - Disconnect a client - """ - def disconnect!(client) do - :gen_server.cast client, :disconnect - end - - @doc """ - Send an event to a client's event handlers - """ - def notify!(pid, event) do - :gen_server.cast pid, {:notify, event} - end - - ##################### - # GenServer API - ##################### - - def start_link() do - :gen_server.start_link(__MODULE__, nil, []) - end - - def init(_) do - # Start the event handler - {:ok, events} = :gen_event.start_link() - {:ok, ClientState.new([events: events])} - end - - @doc """ - Handles connecting the client to the provided IRC server - """ - def handle_cast({:connect, connection}, state) do - {:noreply, state} - end - - @doc """ - Handles adding a new event handler (i.e bot) to the client - """ - def handle_cast({:add_handler, handler, args}, state) do - :gen_event.add_sup_handler(state.events, handler, args) - {:noreply, state} - end - - @doc """ - Handles event notifications - """ - def handle_cast({:notify, event}, state) do - :gen_event.notify(state.events, event) - {:noreply, state} - end - - @doc """ - Handles event handler termination. Specifically, it restarts handlers which have crashed. - """ - def handle_info({:gen_event_EXIT, handler, reason}, state) do - case reason do - :normal -> {:noreply, state.events} - :shutdown -> {:noreply, state.events} - {:swapped, _, _} -> {:noreply, state.events} - _ -> - :gen_server.cast(self, {:add_handler, handler, []}) - warning "Handler #{atom_to_binary(handler)} crashed. Restarting..." - {:noreply, state} - end - end -end \ No newline at end of file diff --git a/lib/exirc/exirc_example_handler.ex b/lib/exirc/exirc_example_handler.ex deleted file mode 100644 index 52e6b5b..0000000 --- a/lib/exirc/exirc_example_handler.ex +++ /dev/null @@ -1,21 +0,0 @@ -defmodule ExIrc.ExampleHandler do - use GenEvent.Behaviour - - ################ - # GenEvent API - ################ - - def init(args) do - {:ok, args} - end - - def handle_event(:connected, state) do - IO.puts "Received event :connected" - {:ok, state} - end - def handle_event(:login, state) do - IO.puts "Received event :login" - {:ok, state} - end - -end diff --git a/lib/exirc/logger.ex b/lib/exirc/logger.ex index c6ac20d..35b80d2 100644 --- a/lib/exirc/logger.ex +++ b/lib/exirc/logger.ex @@ -1,9 +1,13 @@ -defmodule Logger do +defmodule ExIrc.Logger do + def notice(msg) do + IO.puts(IO.ANSI.cyan() <> msg <> IO.ANSI.reset()) + end + def warning(msg) do IO.puts(IO.ANSI.magenta() <> msg <> IO.ANSI.reset()) end def error(msg) do IO.puts(IO.ANSI.red() <> msg <> IO.ANSI.reset()) end end \ No newline at end of file diff --git a/lib/exirc/utils.ex b/lib/exirc/utils.ex new file mode 100644 index 0000000..6ffba7c --- /dev/null +++ b/lib/exirc/utils.ex @@ -0,0 +1,118 @@ +defmodule ExIrc.Utils do + + alias ExIrc.Client.IrcMessage, as: IrcMessage + + @doc """ + Parse IRC message data + """ + def parse(raw_data) do + data = String.slice(raw_data, 1, String.length(raw_data) - 2) + case data do + <<":", _ :: binary>> -> + [<<":", from :: binary>>, rest] = String.split(data, " ") + get_cmd rest, parse_from(from, IrcMessage.new(ctcp: false) + data -> + get_cmd String.split(data, " "), IrcMessage.new(ctcp: false) + end. + end + + def parse_from(from, msg) do + case Regex.split(%r/(!|@|\.)/, from) do + [nick, "!", user, "@", host | host_rest] -> + IrcMessage.new(nick: nick, user: user, host: host <> host_rest) + [nick, "@", host | host_rest] -> + IrcMessage.new(nick: nick, host: host <> host_rest) + [_, "." | _] -> + # from is probably a server name + IrcMessage.new(server: from) + [nick] -> + IrcMessage.new(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) + 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 + [1 | ctcp_rev] -> + [ctcp_cmd | args] = Enum.reverse(ctcp_rev) |> String.split(' ') + msg[cmd: ctcp_cmd, args: args, ctcp: true] + _ -> + msg[cmd: cmd, ctcp: :invalid] + end + end + def get_cmd([cmd | rest], msg) do + get_args(rest, msg.cmd(cmd)) + end + + def get_args([], msg) do + msg.args(Enum.reverse(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 + [_ | []] -> + 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])) + end + def 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 + result = Regex.run(%r/\((.*)\)(.*)/, user_prefixes, [:capture, :all_but_first]) + {match, [{p1, l1}, {p2, l2}]} = result + group1 = String.slice(user_prefixes, p1 + 1, l1) + group2 = String.slice(user_prefixes, p2 + 1, l2) + state.user_prefixes(Enum.zip(group1, group2)) + 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}}) -> + [: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)] + end + +end \ No newline at end of file