diff --git a/lib/exirc/commands.ex b/lib/exirc/commands.ex index 6f2d385..eb54b89 100644 --- a/lib/exirc/commands.ex +++ b/lib/exirc/commands.ex @@ -1,271 +1,271 @@ defmodule Irc.Commands do @moduledoc """ Defines IRC command constants, and methods for generating valid commands to send to an IRC server. """ 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 [ @err_no_nickname_given, @err_erroneus_nickname, @err_nickname_in_use, @err_nick_collision, @err_unavail_resource, @err_need_more_params, @err_already_registered, @err_restricted ] end end ############ # Helpers ############ - @ctcp_delimiter <<0x01>> + @ctcp_delimiter 0o001 @doc """ Send data to a TCP socket. Example: command = pass! "password" send! socket, command """ def send!(socket, data) do :gen_tcp.send(socket, data) end @doc """ Builds a valid IRC command. """ def command!(cmd), do: [cmd, '\r\n'] @doc """ Builds a valid CTCP command. """ def ctcp!(cmd), do: command! [@ctcp_delimiter, cmd, @ctcp_delimiter] def ctcp!(cmd, args) do expanded = args |> Enum.intersperse(' ') command! [@ctcp_delimiter, cmd, expanded, @ctcp_delimiter] end # IRC Commands @doc """ Send password to server """ def pass!(pwd), do: command! ['PASS ', pwd] @doc """ Send nick to server. (Changes or sets your nick) """ def nick!(nick), do: command! ['NICK ', nick] @doc """ Send username to server. (Changes or sets your username) """ def user!(user, name) do command! ['USER ', user, ' 0 * :', name] end @doc """ Send PONG in response to PING """ def pong1!(nick), do: command! ['PONG ', nick] @doc """ Send a targeted PONG in response to PING """ def pong2!(nick, to), do: command! ['PONG ', nick, ' ', to] @doc """ Send message to channel or user """ def privmsg!(nick, msg), do: command! ['PRIVMSG ', nick, ' :', msg] @doc """ Send a `/me ` CTCP command to t """ def me!(channel, msg), do: command! ['PRIVMSG ', channel, ' :', @ctcp_delimiter, 'ACTION ', msg, @ctcp_delimiter] @doc """ Send notice to channel or user """ def notice!(nick, msg), do: command! ['NOTICE ', nick, ' :', msg] @doc """ Send join command to server (join a channel) """ def join!(channel), do: command! ['JOIN ', channel] def join!(channel, key), do: command! ['JOIN ', channel, ' ', key] @doc """ Send part command to server (leave a channel) """ def part!(channel), do: command! ['PART ', channel] @doc """ Send quit command to server (disconnect from server) """ def quit!(msg \\ "Leaving"), do: command! ['QUIT :', msg] @doc """ Send kick command to server """ def kick!(channel, nick, message \\ "") do case "#{message}" |> String.length do 0 -> command! ['KICK ', channel, ' ', nick] _ -> command! ['KICK ', channel, ' ', nick, ' ', message] end end @doc """ Send mode command to server MODE MODE [] """ def mode!(channel_or_nick, flags, args \\ "") do case "#{args}" |> String.length do 0 -> command! ['MODE ', channel_or_nick, ' ', flags] _ -> command! ['MODE ', channel_or_nick, ' ', flags, ' ', args] end end @doc """ Send an invite command """ def invite!(nick, channel) do command! ['INVITE ', nick, ' ', channel] end end diff --git a/lib/exirc/utils.ex b/lib/exirc/utils.ex index ae33a56..db2623c 100644 --- a/lib/exirc/utils.ex +++ b/lib/exirc/utils.ex @@ -1,175 +1,175 @@ defmodule ExIrc.Utils do ###################### # IRC Message Parsing ###################### @doc """ Parse an IRC message Example: data = ':irc.example.org 005 nick NETWORK=Freenode PREFIX=(ov)@+ CHANTYPES=#&' message = ExIrc.Utils.parse data assert "irc.example.org" = message.server """ @spec parse(raw_data :: char_list) :: IrcMessage.t def parse(raw_data) do data = :string.substr(raw_data, 1, length(raw_data)) case data do [?:|_] -> [[?:|from]|rest] = :string.tokens(data, ' ') get_cmd(rest, parse_from(from, %IrcMessage{ctcp: false})) data -> get_cmd(:string.tokens(data, ' '), %IrcMessage{ctcp: false}) end end defp parse_from(from, msg) do binary_from = IO.iodata_to_binary(from) fully_qualified_regex = ~r/(?.*)!(?.*)@(?.*)/ missing_user_regex = ~r/(?.*)@(?.*)/ host_only_regex = ~r/.+\..+/ cond do captures = Regex.named_captures fully_qualified_regex, binary_from -> %{msg | :nick => captures[:nick], :user => captures[:user], :host => captures[:host]} captures = Regex.named_captures missing_user_regex, binary_from -> %{msg | :nick => captures[:nick], :host => captures[:host]} Regex.match? host_only_regex, binary_from -> %{msg | :server => to_string(from)} true -> %{msg | :nick => to_string(from)} end end # 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 defp get_cmd([cmd, target, [1 | ctcp_cmd] | cmd_args], msg) when cmd == 'PRIVMSG' or cmd == 'NOTICE' do args = cmd_args - |> Enum.map(&Enum.take_while(&1, fn c -> c != ?\001 end)) + |> Enum.map(&Enum.take_while(&1, fn c -> c != 0o001 end)) |> Enum.map(&List.to_string/1) case args do args when args != [] -> %{msg | :cmd => to_string(ctcp_cmd), :args => [to_string(target), args |> Enum.join(" ")], :ctcp => true } _ -> %{msg | :cmd => to_string(cmd), :ctcp => :invalid} end end defp get_cmd([cmd | rest], msg) do get_args(rest, %{msg | :cmd => to_string(cmd)}) end # Parse command args from message defp get_args([], msg) do args = msg.args |> Enum.reverse |> Enum.filter(fn(arg) -> arg != [] end) |> Enum.map(&List.to_string/1) %{msg | :args => args} end defp get_args([[?: | first_arg] | rest], msg) do args = (for arg <- [first_arg | rest], do: ' ' ++ trim_crlf(arg)) |> List.flatten case args do [_ | []] -> get_args [], %{msg | :args => [msg.args]} [_ | full_trail] -> get_args [], %{msg | :args => [full_trail | msg.args]} end end defp get_args([arg | []], msg) do get_args [], %{msg | :args => [arg | msg.args]} end defp get_args([arg | rest], msg) do get_args rest, %{msg | :args => [arg | msg.args]} end ############################ # Parse RPL_ISUPPORT (005) ############################ @doc """ Parse RPL_ISUPPORT message. If an empty list is provided, do nothing, otherwise parse CHANTYPES, NETWORK, and PREFIX parameters for relevant data. """ @spec isup(parameters :: list(binary), state :: ExIrc.Client.ClientState.t) :: ExIrc.Client.ClientState.t 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 defp isup_param("CHANTYPES=" <> channel_prefixes, state) do prefixes = channel_prefixes |> String.split("", trim: true) %{state | :channel_prefixes => prefixes} end defp isup_param("NETWORK=" <> network, state) do %{state | :network => network} end defp isup_param("PREFIX=" <> user_prefixes, state) do prefixes = Regex.run(~r/\((.*)\)(.*)/, user_prefixes, capture: :all_but_first) |> Enum.map(&String.to_char_list/1) |> List.zip %{state | :user_prefixes => prefixes} end defp isup_param(_, state) do state end ################### # Helper Functions ################### @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'] @doc """ Get CTCP formatted time from a tuple representing the current calendar time: Example: - iex> local_time = {{2013,12,6},{14,5,00}} - {{2013,12,6},{14,5,00}} + iex> local_time = {{2013,12,6},{14,5,0}} + {{2013,12,6},{14,5,0}} iex> ExIrc.Utils.ctcp_time local_time "Fri Dec 06 14:05:00 2013" """ @spec ctcp_time(datetime :: {{integer, integer, integer}, {integer, integer, integer}}) :: binary def ctcp_time({{y, m, d}, {h, n, s}} = _datetime) 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_char_list(d)]), ' ', :io_lib.format("~2..0s", [Integer.to_char_list(h)]), ':', :io_lib.format("~2..0s", [Integer.to_char_list(n)]), ':', :io_lib.format("~2..0s", [Integer.to_char_list(s)]), ' ', Integer.to_char_list(y)] |> List.flatten |> List.to_string end defp trim_crlf(charlist) do case Enum.reverse(charlist) do [?\n, ?\r | text] -> Enum.reverse(text) _ -> charlist end end end diff --git a/test/commands_test.exs b/test/commands_test.exs index c84fce3..e9bdaef 100644 --- a/test/commands_test.exs +++ b/test/commands_test.exs @@ -1,48 +1,48 @@ defmodule ExIrc.CommandsTest do use ExUnit.Case, async: true use Irc.Commands test "Commands are formatted properly" do - expected = <<1, "TESTCMD", 1, ?\r, ?\n>> + expected = <<0o001, "TESTCMD", 0o001, ?\r, ?\n>> assert expected == ctcp!("TESTCMD") |> IO.iodata_to_binary - expected = <<"PRIVMSG #testchan :", 0x01, "ACTION mind explodes!!", 0x01, ?\r, ?\n>> + expected = <<"PRIVMSG #testchan :", 0o001, "ACTION mind explodes!!", 0o001, ?\r, ?\n>> assert expected == me!("#testchan", "mind explodes!!") |> IO.iodata_to_binary expected = <<"PASS testpass", ?\r, ?\n>> assert expected == pass!("testpass") |> IO.iodata_to_binary expected = <<"NICK testnick", ?\r, ?\n>> assert expected == nick!("testnick") |> IO.iodata_to_binary expected = <<"USER testuser 0 * :Test User", ?\r, ?\n>> assert expected == user!("testuser", "Test User") |> IO.iodata_to_binary expected = <<"PONG testnick", ?\r, ?\n>> assert expected == pong1!("testnick") |> IO.iodata_to_binary expected = <<"PONG testnick othernick", ?\r, ?\n>> assert expected == pong2!("testnick", "othernick") |> IO.iodata_to_binary expected = <<"PRIVMSG testnick :Test message!", ?\r, ?\n>> assert expected == privmsg!("testnick", "Test message!") |> IO.iodata_to_binary expected = <<"NOTICE testnick :Test notice!", ?\r, ?\n>> assert expected == notice!("testnick", "Test notice!") |> IO.iodata_to_binary expected = <<"JOIN testchan", ?\r, ?\n>> assert expected == join!("testchan") |> IO.iodata_to_binary expected = <<"JOIN testchan chanpass", ?\r, ?\n>> assert expected == join!("testchan", "chanpass") |> IO.iodata_to_binary expected = <<"PART testchan", ?\r, ?\n>> assert expected == part!("testchan") |> IO.iodata_to_binary expected = <<"QUIT :Leaving", ?\r, ?\n>> assert expected == quit! |> IO.iodata_to_binary expected = <<"QUIT :Goodbye, cruel world.", ?\r, ?\n>> assert expected == quit!("Goodbye, cruel world.") |> IO.iodata_to_binary expected = <<"KICK #testchan testuser", ?\r, ?\n>> assert expected == kick!("#testchan", "testuser") |> IO.iodata_to_binary expected = <<"KICK #testchan testuser Get outta here!", ?\r, ?\n>> assert expected == kick!("#testchan", "testuser", "Get outta here!") |> IO.iodata_to_binary expected = <<"MODE testuser -o", ?\r, ?\n>> assert expected == mode!("testuser", "-o") |> IO.iodata_to_binary expected = <<"MODE #testchan +im", ?\r, ?\n>> assert expected == mode!("#testchan", "+im") |> IO.iodata_to_binary expected = <<"MODE #testchan +o testuser", ?\r, ?\n>> assert expected == mode!("#testchan", "+o", "testuser") |> IO.iodata_to_binary expected = <<"INVITE testuser #testchan", ?\r, ?\n>> assert expected == invite!("testuser", "#testchan") |> IO.iodata_to_binary end end \ No newline at end of file diff --git a/test/utils_test.exs b/test/utils_test.exs index e079661..835af64 100644 --- a/test/utils_test.exs +++ b/test/utils_test.exs @@ -1,57 +1,57 @@ defmodule ExIrc.UtilsTest do use ExUnit.Case, async: true use Irc.Commands alias ExIrc.Utils, as: Utils alias ExIrc.Client.ClientState, as: ClientState doctest ExIrc.Utils 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}} # Mimics output of :calendar.local_time() + local_time = {{2013,12,6},{14,5,0}} # Mimics output of :calendar.local_time() assert Utils.ctcp_time(local_time) == "Fri Dec 06 14:05:00 2013" end test "Can parse a CTCP command" do - message = ':pschoenf NOTICE #testchan :\001ACTION mind explodes!!\001' + message = ':pschoenf NOTICE #testchan :' ++ '#{<<0o001>>}' ++ 'ACTION mind explodes!!' ++ '#{<<0o001>>}' expected = %IrcMessage{ nick: "pschoenf", cmd: "ACTION", ctcp: true, args: ["#testchan", "mind explodes!!"] } result = Utils.parse(message) assert expected == result end test "Parse INVITE message" do message = ':pschoenf INVITE testuser #awesomechan' assert %IrcMessage{ :nick => "pschoenf", :cmd => "INVITE", :args => ["testuser", "#awesomechan"] } = Utils.parse(message) end test "Parse KICK message" do message = ':pschoenf KICK #testchan lameuser' assert %IrcMessage{ :nick => "pschoenf", :cmd => "KICK", :args => ["#testchan", "lameuser"] } = Utils.parse(message) end test "Can parse RPL_ISUPPORT commands" do message = ':irc.example.org 005 nick NETWORK=Freenode PREFIX=(ov)@+ CHANTYPES=#&' parsed = Utils.parse(message) state = %ClientState{} assert %ClientState{ :channel_prefixes => ["#", "&"], :user_prefixes => [{?o, ?@}, {?v, ?+}], :network => "Freenode" } = Utils.isup(parsed.args, state) end end