diff --git a/lib/exirc/utils.ex b/lib/exirc/utils.ex index 2573cc3..46cba11 100644 --- a/lib/exirc/utils.ex +++ b/lib/exirc/utils.ex @@ -1,182 +1,188 @@ 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 @prefix_pattern ~r/^(?[^!\s]+)(?:!(?:(?[^@\s]+)@)?(?:(?[\S]+)))?$/ defp parse_from(from, msg) do from_str = IO.iodata_to_binary(from) parts = Regex.run(@prefix_pattern, from_str, capture: :all_but_first) case parts do [nick, user, host] -> %{msg | nick: nick, user: user, host: host} [nick, host] -> %{msg | nick: nick, host: host} [nick] -> if String.contains?(nick, ".") do %{msg | server: nick} else %{msg | nick: nick} end 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 != 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(&trim_crlf/1) |> Enum.map(&:binary.list_to_bin/1) - |> Enum.map(&:unicode.characters_to_binary/1) + |> Enum.map(fn(s) -> + case String.valid?(s) do + true -> :unicode.characters_to_binary(s) + false -> :unicode.characters_to_binary(s, :latin1, :unicode) + end + end) + post_process(%{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 | rest], msg) do get_args(rest, %{msg | args: [arg | msg.args]}) end # This function allows us to handle special case messages which are not RFC # compliant, before passing it to the client. defp post_process(%IrcMessage{cmd: "332", args: [nick, channel]} = msg) do # Handle malformed RPL_TOPIC messages which contain no topic %{msg | :cmd => "331", :args => [channel, "No topic is set"], :nick => nick} end defp post_process(msg), do: msg ############################ # 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,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/utils_test.exs b/test/utils_test.exs index dce38c0..3beb447 100644 --- a/test/utils_test.exs +++ b/test/utils_test.exs @@ -1,192 +1,209 @@ 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,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 :' ++ '#{<<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 cloaked user" do message = ':foo!foo@unaffiliated/foo PRIVMSG #bar Hiya.' expected = %IrcMessage{ nick: "foo", cmd: "PRIVMSG", host: "unaffiliated/foo", ctcp: false, user: "foo", args: ["#bar", "Hiya."] } result = Utils.parse(message) assert expected == result end test "Parse uncloaked (normal) user" do message = ':foo!foo@80.21.56.43 PRIVMSG #bar Hiya.' expected = %IrcMessage{ nick: "foo", cmd: "PRIVMSG", host: "80.21.56.43", ctcp: false, user: "foo", args: ["#bar", "Hiya."] } 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 test "Can parse full prefix in messages" do assert %IrcMessage{ nick: "WiZ", user: "jto", host: "tolsun.oulu.fi", } = Utils.parse(':WiZ!jto@tolsun.oulu.fi NICK Kilroy') end test "Can parse prefix with only hostname in messages" do assert %IrcMessage{ nick: "WiZ", host: "tolsun.oulu.fi", } = Utils.parse(':WiZ!tolsun.oulu.fi NICK Kilroy') end test "Can parse reduced prefix in messages" do assert %IrcMessage{ nick: "Trillian", } = Utils.parse(':Trillian SQUIT cm22.eng.umd.edu :Server out of control') end test "Can parse server-only prefix in messages" do assert %IrcMessage{ server: "ircd.stealth.net" } = Utils.parse(':ircd.stealth.net 302 yournick :syrk=+syrk@millennium.stealth.net') end test "Can parse FULL STOP in username in prefixes" do assert %IrcMessage{ nick: "nick", user: "user.name", host: "irc.example.org" } = Utils.parse(':nick!user.name@irc.example.org PART #channel') end test "Can parse EXCLAMATION MARK in username in prefixes" do assert %IrcMessage{ nick: "nick", user: "user!name", host: "irc.example.org" } = Utils.parse(':nick!user!name@irc.example.org PART #channel') end test "parse join message" do message = ':pschoenf JOIN #elixir-lang' assert %IrcMessage{ nick: "pschoenf", cmd: "JOIN", args: ["#elixir-lang"] } = Utils.parse(message) end test "Parse Slack's inappropriate RPL_TOPIC message as if it were an RPL_NOTOPIC" do # NOTE: This is not a valid message per the RFC. If there's no topic # (which is the case for Slack in this instance), they should instead send # us a RPL_NOTOPIC (331). # # Two things: # # 1) Bad slack! Read your RFCs! (because my code has never had bugs yup obv) # 2) Don't care, still want to talk to them without falling over dead! # # Parsing this as if it were actually an RPL_NOTOPIC (331) seems especially like # a good idea when I realized that there's nothing in ExIRc that does anything # with 331 at all - they just fall on the floor, no crashes to be seen (ideally) message = ':irc.tinyspeck.com 332 jadams #elm-playground-news :' assert %IrcMessage{ nick: "jadams", cmd: "331", args: ["#elm-playground-news", "No topic is set"] } = Utils.parse(message) end test "Can parse simple unicode" do # ':foo!~user@172.17.0.1 PRIVMSG #bar :éáçíóö\r\n' message = [58, 102, 111, 111, 33, 126, 117, 115, 101, 114, 64, 49, 55, 50, 46, 49, 55, 46, 48, 46, 49, 32, 80, 82, 73, 86, 77, 83, 71, 32, 35, 98, 97, 114, 32, 58, 195, 169, 195, 161, 195, 167, 195, 173, 195, 179, 195, 182, 13, 10] assert %IrcMessage{ args: ["#bar", "éáçíóö"], cmd: "PRIVMSG", ctcp: false, host: "172.17.0.1", nick: "foo", server: [], user: "~user" } = Utils.parse(message) end test "Can parse complex unicode" do # ':foo!~user@172.17.0.1 PRIVMSG #bar :Ĥélłø 차\r\n' message = [58, 102, 111, 111, 33, 126, 117, 115, 101, 114, 64, 49, 55, 50, 46, 49, 55, 46, 48, 46, 49, 32, 80, 82, 73, 86, 77, 83, 71, 32, 35, 98, 97, 114, 32, 58, 196, 164, 195, 169, 108, 197, 130, 195, 184, 32, 236, 176, 168, 13, 10] assert %IrcMessage{ args: ["#bar", "Ĥélłø 차"], cmd: "PRIVMSG", ctcp: false, host: "172.17.0.1", nick: "foo", server: [], user: "~user" } = Utils.parse(message) end + test "Can parse latin1" do + # ':foo!~user@172.17.0.1 PRIVMSG #bar :ééé\r\n' + message = [58, 102, 111, 111, 33, 126, 117, 115, 101, 114, 64, 49, 55, 50, + 46, 49, 55, 46, 48, 46, 49, 32, 80, 82, 73, 86, 77, 83, 71, 32, + 35, 98, 97, 114, 32, 58, 233, 233, 233, 13, 10] + + assert %IrcMessage{ + args: ["#bar", "ééé"], + cmd: "PRIVMSG", + ctcp: false, + host: "172.17.0.1", + nick: "foo", + server: [], + user: "~user" + } = Utils.parse(message) + end + end