diff --git a/CHANGES.md b/CHANGES.md index 16bb900..3be72ae 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,7 @@ * Use `Set` instead of `Array` where appropriate -* Allow trailing `\r\n` or `\n` in arguments to `#parse` and `#decompose_line` +* Allow trailing `\r\n` or `\n` in arguments to `#parse` and `#decompose` * Call `#inspect` on unsupported protocol lines +* Used a struct to represent IRC protocol lines and simplified some code 0.2.0 Mon Dec 3 18:27:18 2012 +0000 ----- diff --git a/lib/ircsupport/masks.rb b/lib/ircsupport/masks.rb index 6185932..f14b572 100644 --- a/lib/ircsupport/masks.rb +++ b/lib/ircsupport/masks.rb @@ -28,7 +28,7 @@ def matches_mask(mask, string, casemapping = :rfc1459) end # Match strings to multiple IRC masks. - # @param [Array] mask The masks to match against. + # @param [Array] masks The masks to match against. # @param [Array] strings The strings to match against the masks. # @param [Symbol] casemapping The IRC casemapping to use in the match. # @return [Hash] Each mask that was matched will be present as a key, diff --git a/lib/ircsupport/message.rb b/lib/ircsupport/message.rb index c8e83b8..1c99d26 100644 --- a/lib/ircsupport/message.rb +++ b/lib/ircsupport/message.rb @@ -14,10 +14,10 @@ class Message attr_accessor :args # @private - def initialize(args) - @prefix = args[:prefix] - @command = args[:command] - @args = args[:args] + def initialize(line, isupport, capabilities) + @prefix = line.prefix + @command = line.command + @args = line.args end # @return [Symbol] The type of the IRC message. @@ -33,8 +33,8 @@ class Numeric < Message attr_accessor :name # @private - def initialize(args) - super(args) + def initialize(line, isupport, capabilities) + super @name = IRCSupport::Numerics.numeric_to_name(@command) @type = @command.to_sym end @@ -98,10 +98,10 @@ class Numeric005 < Numeric attr_accessor :isupport # @private - def initialize(args) - super(args) + def initialize(line, isupport, capabilities) + super @isupport = {} - args[:args].each do |value| + @args.each do |value| name, value = value.split(/=/, 2) if value proc = @@isupport_mappings.find {|key, _| key.include?(name)} @@ -125,13 +125,13 @@ class Numeric353 < Numeric attr_accessor :users # @private - def initialize(args) - super(args) + def initialize(line, isupport, capabilities) + super data = @args.last(@args.size - 1) @channel_type = data.shift if data[0] =~ /^[@=*]$/ @channel = data[0] @users = [] - prefixes = args[:isupport]["PREFIX"].values.map { |p| Regexp.quote p } + prefixes = isupport["PREFIX"].values.map { |p| Regexp.quote p } data[1].split(/\s+/).each do |user| user.sub! /^(#{prefixes.join '|'})/, '' @@ -170,8 +170,8 @@ class Numeric352 < Numeric attr_accessor :realname # @private - def initialize(args) - super(args) + def initialize(line, isupport, capabilities) + super @target, @username, @hostname, @server, @nickname, status, rest = @args.last(@args.size - 1) status.sub! /[GH]/, '' @@ -193,11 +193,11 @@ class DCC < Message attr_accessor :dcc_args # @private - def initialize(args) - super(args) - @sender = args[:prefix] - @dcc_args = args[:args][1] - @dcc_type = args[:dcc_type].downcase.to_sym + def initialize(line, isupport, capabilities, dcc_type) + super(line, isupport, capabilities) + @sender = @prefix + @dcc_args = @args[1] + @dcc_type = dcc_type.downcase.to_sym @type = "dcc_#@dcc_type".to_sym end end @@ -210,8 +210,8 @@ class DCC::Chat < DCC attr_accessor :port # @private - def initialize(args) - super(args) + def initialize(line, isupport, capabilities, dcc_type) + super return if @dcc_args !~ /^(?:".+"|[^ ]+) +(\d+) +(\d+)/ @address = IPAddr.new($1.to_i, Socket::AF_INET) @port = $2.to_i @@ -232,8 +232,8 @@ class DCC::Send < DCC attr_accessor :size # @private - def initialize(args) - super(args) + def initialize(line, isupport, capabilities, dcc_type) + super return if @dcc_args !~ /^(".+"|[^ ]+) +(\d+) +(\d+)(?: +(\d+))?/ @filename = $1 @address = IPAddr.new($2.to_i, Socket::AF_INET) @@ -260,8 +260,8 @@ class DCC::Accept < DCC attr_accessor :position # @private - def initialize(args) - super(args) + def initialize(line, isupport, capabilities, dcc_type) + super return if @dcc_args !~ /^(".+"|[^ ]+) +(\d+) +(\d+)/ @filename = $1 @port = $2.to_i @@ -283,9 +283,9 @@ class Error < Message attr_accessor :error # @private - def initialize(args) - super(args) - @error = args[:args][0] + def initialize(line, isupport, capabilities) + super + @error = @args[0] end end @@ -297,10 +297,10 @@ class Invite < Message attr_accessor :channel # @private - def initialize(args) - super(args) - @inviter = args[:prefix] - @channel = args[:args][1] + def initialize(line, isupport, capabilities) + super + @inviter = @prefix + @channel = @args[1] end end @@ -312,10 +312,10 @@ class Join < Message attr_accessor :channel # @private - def initialize(args) - super(args) - @joiner = args[:prefix] - @channel = args[:args][0] + def initialize(line, isupport, capabilities) + super + @joiner = @prefix + @channel = @args[0] end end @@ -330,11 +330,11 @@ class Part < Message attr_accessor :message # @private - def initialize(args) - super(args) - @parter = args[:prefix] - @channel = args[:args][0] - @message = args[:args][1] + def initialize(line, isupport, capabilities) + super + @parter = @prefix + @channel = @args[0] + @message = @args[1] @message = nil if @message && @message.empty? end end @@ -353,12 +353,12 @@ class Kick < Message attr_accessor :message # @private - def initialize(args) - super(args) - @kicker = args[:prefix] - @channel = args[:args][0] - @kickee = args[:args][1] - @message = args[:args][2] + def initialize(line, isupport, capabilities) + super + @kicker = @prefix + @channel = @args[0] + @kickee = @args[1] + @message = @args[2] @message = nil if @message && @message.empty? end end @@ -369,9 +369,9 @@ class UserModeChange < Message attr_accessor :mode_changes # @private - def initialize(args) - super(args) - @mode_changes = IRCSupport::Modes.parse_modes(args[:args][0]) + def initialize(line, isupport, capabilities) + super + @mode_changes = IRCSupport::Modes.parse_modes(@args[0]) end end @@ -387,14 +387,14 @@ class ChannelModeChange < Message attr_accessor :mode_changes # @private - def initialize(args) - super(args) - @changer = args[:prefix] - @channel = args[:args][0] + def initialize(line, isupport, capabilities) + super + @changer = @prefix + @channel = @args[0] @mode_changes = IRCSupport::Modes.parse_channel_modes( - args[:args].last(args[:args].size - 1), - chanmodes: args[:isupport]["CHANMODES"], - statmodes: args[:isupport]["PREFIX"].keys, + @args.last(@args.size - 1), + chanmodes: isupport["CHANMODES"], + statmodes: isupport["PREFIX"].keys, ) end end @@ -407,10 +407,10 @@ class Nick < Message attr_accessor :nickname # @private - def initialize(args) - super(args) - @changer = args[:prefix] - @nickname = args[:args][0] + def initialize(line, isupport, capabilities) + super + @changer = @prefix + @nickname = @args[0] end end @@ -425,11 +425,11 @@ class Topic < Message attr_accessor :topic # @private - def initialize(args) - super(args) - @changer = args[:prefix] - @channel = args[:args][0] - @topic = args[:args][1] + def initialize(line, isupport, capabilities) + super + @changer = @prefix + @channel = @args[0] + @topic = @args[1] @topic = nil if @topic && @topic.empty? end end @@ -442,10 +442,10 @@ class Quit < Message attr_accessor :message # @private - def initialize(args) - super(args) - @quitter = args[:prefix] - @message = args[:args][0] + def initialize(line, isupport, capabilities) + super + @quitter = @prefix + @message = @args[0] @message = nil if @message && @message.empty? end end @@ -455,9 +455,9 @@ class Ping < Message attr_accessor :message # @private - def initialize(args) - super(args) - @message = args[:args][0] + def initialize(line, isupport, capabilities) + super + @message = @args[0] @message = nil if @message && @message.empty? end end @@ -473,16 +473,16 @@ class CAP < Message attr_accessor :reply # @private - def initialize(args) - super(args) - @subcommand = args[:args][0] + def initialize(line, isupport, capabilities) + super + @subcommand = @args[0] @type = "cap_#{@subcommand.downcase}".to_sym - if args[:args][1] == '*' + if @args[1] == '*' @multipart = true - @reply = args[:args][2] + @reply = @args[2] else @multipart = false - @reply = args[:args][1] + @reply = @args[1] end end end @@ -501,8 +501,8 @@ class CAP::LS < CAP } # @private - def initialize(args) - super(args) + def initialize(line, isupport, capabilities) + super @capabilities = {} reply.split.each do |chunk| @@ -533,14 +533,14 @@ class ServerNotice < Message attr_accessor :message # @private - def initialize(args) - super(args) - @sender = args[:prefix] - if args[:args].size == 2 - @target = args[:args][0] - @message = args[:args][1] + def initialize(line, isupport, capabilities) + super + @sender = @prefix + if @args.size == 2 + @target = @args[0] + @message = @args[1] else - @message = args[:args][0] + @message = @args[0] end end end @@ -557,20 +557,23 @@ class Message < Message attr_accessor :channel # @private - def initialize(args) - super(args) - @sender = args[:prefix] - @message = args[:args][1] - @is_action = args[:is_action] || false - @is_notice = args[:is_notice] || false - - if args[:is_public] + def initialize(line, isupport, capabilities, is_action = false) + super(line, isupport, capabilities) + + @sender = @prefix + @message = @args[1] + @is_action = is_action + @is_notice = true if @command == "NOTICE" + @is_public = true if isupport['CHANTYPES'].include?(@args[0][0]) + + if @is_public # broadcast messages are so 90s - @channel = args[:args][0].split(/,/).first + @channel = @args[0].split(/,/).first end - if args[:capabilities].include?('identify-msg') - @identified = args[:identified] + if capabilities.include?('identify-msg') + @identified, @message = @message.split(//, 2) + @identified = @identified == '+' ? true : false def self.identified?; @identified; end end end @@ -590,23 +593,23 @@ class CTCP < Message attr_accessor :ctcp_args # @private - def initialize(args) - super(args) - @sender = args[:prefix] - @ctcp_args = args[:args][1] - @ctcp_type = args[:ctcp_type].downcase.to_sym + def initialize(line, isupport, capabilities, ctcp_type) + super(line, isupport, capabilities) + @sender = @prefix + @ctcp_args = @args[1] + @ctcp_type = ctcp_type.downcase.to_sym @type = "ctcp_#@ctcp_type".to_sym - if args[:is_public] - @channel = args[:args][0].split(/,/).first + if @is_public + @channel = @args[0].split(/,/).first end end end class CTCPReply < CTCP # @private - def initialize(args) - super(args) + def initialize(line, isupport, capabilities, ctcp_type) + super @type = "ctcpreply_#@ctcp_type".to_sym end end diff --git a/lib/ircsupport/modes.rb b/lib/ircsupport/modes.rb index a581730..dbd2fee 100644 --- a/lib/ircsupport/modes.rb +++ b/lib/ircsupport/modes.rb @@ -30,7 +30,7 @@ def parse_modes(modes) # a boolean indicating whether the mode is being set (instead of unset); # `:mode`, the mode character; and `:argument`, the argument to the mode, # if any. - def parse_channel_modes(modeparts, opts = {}) + def parse_channel_modes(modes, opts = {}) chanmodes = opts[:chanmodes] || { 'A' => %w{b e I}.to_set, 'B' => %w{k}.to_set, @@ -40,8 +40,8 @@ def parse_channel_modes(modeparts, opts = {}) statmodes = opts[:statmodes] || %w{o h v}.to_set mode_changes = [] - modes, *args = modeparts - parse_modes(modes).each do |mode_change| + modelist, *args = modes + parse_modes(modelist).each do |mode_change| set, mode = mode_change[:set], mode_change[:mode] case when chanmodes["A"].include?(mode) || chanmodes["B"].include?(mode) diff --git a/lib/ircsupport/parser.rb b/lib/ircsupport/parser.rb index 5c3d46b..e3dd1d7 100644 --- a/lib/ircsupport/parser.rb +++ b/lib/ircsupport/parser.rb @@ -1,6 +1,8 @@ require 'ircsupport/message' module IRCSupport + Line = Struct.new(:prefix, :command, :args) + class Parser # @private @@illegal = '\x00\x0a\x0d' @@ -91,110 +93,95 @@ def initialize end # Perform low-level parsing of an IRC protocol line. - # @param [String] line An IRC protocol line you wish to decompose. - # @return [Hash] A decomposed IRC protocol line with 3 keys: - # `command`, the IRC command; `prefix`, the prefix to the - # command, if any; `args`, an array of any arguments to the command - def decompose_line(line) - if line =~ @@irc_line + # @param [String] raw_line An IRC protocol line you wish to decompose. + # @return [IRCSupport::Line] An IRC protocol line object. + def decompose(raw_line) + if raw_line =~ @@irc_line c = $~ - elems = {} - elems[:prefix] = c[:prefix] if c[:prefix] - elems[:command] = c[:command].upcase - elems[:args] = [] - elems[:args].concat c[:args].split(@@space) if c[:args] - elems[:args] << c[:trailing_arg] if c[:trailing_arg] + line = IRCSupport::Line.new + line.prefix = c[:prefix] if c[:prefix] + line.command = c[:command].upcase + line.args = [] + line.args.concat c[:args].split(@@space) if c[:args] + line.args << c[:trailing_arg] if c[:trailing_arg] else - raise ArgumentError, "Line is not IRC protocol: #{line.inspect}" + raise ArgumentError, "Line is not IRC protocol: #{raw_line.inspect}" end - return elems + return line end # Compose an IRC protocol line. - # @param [Hash] elems The attributes of the message (as returned - # by {#decompose_line}). + # @param [IRCSupport::Line] line An IRC protocol line object + # (as returned by {#decompose}). # @return [String] An IRC protocol line. - def compose_line(elems) - line = '' - line << ":#{elems[:prefix]} " if elems[:prefix] - if !elems[:command] - raise ArgumentError, "You must specify a command" - end - line << elems[:command] - - if elems[:args] - elems[:args].each_with_index do |arg, idx| - line << ' ' - if idx != elems[:args].count-1 and arg.match(@@space) + def compose(line) + raise ArgumentError, "You must specify a command" if !line.command + raw_line = '' + raw_line << ":#{line.prefix} " if line.prefix + raw_line << line.command + + if line.args + line.args.each_with_index do |arg, idx| + raw_line << ' ' + if idx != line.args.count-1 and arg.match(@@space) raise ArgumentError, "Only the last argument may contain spaces" end - if idx == elems[:args].count-1 - line << ':' if arg.match(@@space) + if idx == line.args.count-1 + raw_line << ':' if arg.match(@@space) end - line << arg + raw_line << arg end end - return line + return raw_line end # Parse an IRC protocol line into a complete message object. - # @param [String] line An IRC protocol line. + # @param [String] raw_line An IRC protocol line. # @return [IRCSupport::Message] A parsed message object. - def parse(line) - elems = decompose_line(line) - elems[:isupport] = @isupport - elems[:capabilities] = @capabilities + def parse(raw_line) + line = decompose(raw_line) - if elems[:command] =~ /^(PRIVMSG|NOTICE)$/ && elems[:args][1] =~ /\x01/ - return handle_ctcp_message(elems) + if line.command =~ /^(PRIVMSG|NOTICE)$/ && line.args[1] =~ /\x01/ + return handle_ctcp_message(line) end - if elems[:command] =~ /^\d{3}$/ - msg_class = "Numeric" - elsif elems[:command] == "MODE" - if @isupport['CHANTYPES'].include? elems[:args][0][0] - msg_class = "ChannelModeChange" - else - msg_class = "UserModeChange" - end - elsif elems[:command] == "NOTICE" && (!elems[:prefix] || elems[:prefix] !~ /!/) - msg_class = "ServerNotice" - elsif elems[:command] =~ /^(PRIVMSG|NOTICE)$/ - msg_class = "Message" - elems[:is_notice] = true if elems[:command] == "NOTICE" - if @isupport['CHANTYPES'].include? elems[:args][0][0] - elems[:is_public] = true - end - if @capabilities.include?('identify-msg') - elems[:args][1], elems[:identified] = split_idmsg(elems[:args][1]) - end - elsif elems[:command] == "CAP" && %w{LS LIST ACK}.include?(elems[:args][0]) - msg_class = "CAP::#{elems[:args][0]}" + msg_class = case + when line.command =~ /^\d{3}$/ + "Numeric" + when line.command == "MODE" + @isupport['CHANTYPES'].include?(line.args[0][0]) ? + "ChannelModeChange" : "UserModeChange" + when line.command == "NOTICE" && (!line.prefix || line.prefix !~ /!/) + "ServerNotice" + when line.command =~ /^(PRIVMSG|NOTICE)$/ + "Message" + when line.command == "CAP" && %w{LS LIST ACK}.include?(line.args[0]) + "CAP::#{line.args[0]}" else - msg_class = elems[:command] + line.command end - begin + msg_const = begin if msg_class == "Numeric" begin - msg_const = constantize("IRCSupport::Message::Numeric#{elems[:command]}") + constantize("IRCSupport::Message::Numeric#{line.command}") rescue - msg_const = constantize("IRCSupport::Message::#{msg_class}") + constantize("IRCSupport::Message::#{msg_class}") end else begin - msg_const = constantize("IRCSupport::Message::#{msg_class}") + constantize("IRCSupport::Message::#{msg_class}") rescue - msg_const = constantize("IRCSupport::Message::#{msg_class.capitalize}") + constantize("IRCSupport::Message::#{msg_class.capitalize}") end end rescue - msg_const = constantize("IRCSupport::Message") + constantize("IRCSupport::Message") end - message = msg_const.new(elems) + message = msg_const.new(line, @isupport, @capabilities) if message.type == :'005' @isupport.merge! message.isupport @@ -235,69 +222,52 @@ def constantize(camel_cased_word) constant end - def split_idmsg(line) - identified, line = line.split(//, 2) - identified = identified == '+' ? true : false - return line, identified - end - - def handle_ctcp_message(elems) - ctcp_type = elems[:command] == 'PRIVMSG' ? 'CTCP' : 'CTCPReply' - ctcps, texts = ctcp_dequote(elems[:args][1]) + def handle_ctcp_message(line) + ctcp_type = line.command == 'PRIVMSG' ? 'CTCP' : 'CTCPReply' + ctcps, texts = ctcp_dequote(line.args[1]) # We only process the first CTCP, ignoring extra CTCPs and any # non-CTCPs. Those who send anything in addition to that first CTCP # are probably up to no good (e.g. trying to flood a bot by having it # reply to 20 CTCP VERSIONs at a time). - ctcp = ctcps.first + line.args[1] = ctcps.first - if @capabilities.include?('identify-msg') && ctcp =~ /^.ACTION/ - ctcp, elems[:identified] = split_idmsg(ctcp) + id = @capabilities.include?('identify-msg') ? /./ : // + if line.args[1].sub!(/^#{id}\KACTION /, '') + return IRCSupport::Message::Message.new(line, @isupport, @capabilities, true) end - if ctcp !~ /^(\w+)(?: (.*))?/ - warn "Received malformed CTCP from #{elems[:prefix]}: #{ctcp}" + if line.args[1] !~ /^(\w+)(?: (.*))?/ + warn "Received malformed CTCP from #{line.prefix}: #{line.args[1]}" return end ctcp_name, ctcp_args = $~.captures if ctcp_name == 'DCC' if ctcp_args !~ /^(\w+) +(.+)/ - warn "Received malformed DCC request from #{elems[:prefix]}: #{ctcp}" + warn "Received malformed DCC request from #{line.prefix}: #{line.args[1]}" return end dcc_name, dcc_args = $~.captures - elems[:args][1] = dcc_args - elems[:dcc_type] = dcc_name + line.args[1] = dcc_args - begin - message_class = constantize("IRCSupport::Message::DCC::" + dcc_name.capitalize) + message_class = begin + constantize("IRCSupport::Message::DCC::" + dcc_name.capitalize) rescue - message_class = constantize("IRCSupport::Message::DCC") + constantize("IRCSupport::Message::DCC") end - return message_class.new(elems) + return message_class.new(line, @isupport, @capabilities, dcc_name) else - elems[:args][1] = ctcp_args || '' - - if @isupport['CHANTYPES'].include? elems[:args][0][0] - elems[:is_public] = true - end - - # treat CTCP ACTIONs as normal messages with a special attribute - if ctcp_name == 'ACTION' - elems[:is_action] = true - return IRCSupport::Message::Message.new(elems) - end + line.args[1] = ctcp_args || '' - begin - message_class = constantize("IRCSupport::Message::#{ctcp_type}_" + ctcp_name.capitalize) + message_class = begin + constantize("IRCSupport::Message::#{ctcp_type}_" + ctcp_name.capitalize) rescue - message_class = constantize("IRCSupport::Message::#{ctcp_type}") + constantize("IRCSupport::Message::#{ctcp_type}") end - elems[:ctcp_type] = ctcp_name - return message_class.new(elems) + return message_class.new(line, @isupport, @capabilities, ctcp_name) end end diff --git a/test/parser_test.rb b/test/parser_test.rb index c4498ed..731aab9 100644 --- a/test/parser_test.rb +++ b/test/parser_test.rb @@ -5,32 +5,32 @@ describe "Parse" do parser = IRCSupport::Parser.new raw_line = ":pretend.dancer.server 005 CPAN MODES=4 CHANLIMIT=#:20 NICKLEN=16 USERLEN=10 HOSTLEN=63 TOPICLEN=450 KICKLEN=450 CHANNELLEN=30 KEYLEN=23 CHANTYPES=# PREFIX=(ov)@+ CASEMAPPING=ascii CAPAB IRCD=dancer :are available on this server" - result = parser.decompose_line(raw_line) + line = parser.decompose(raw_line) it "should parse the server message" do - result[:prefix].must_equal "pretend.dancer.server" - result[:command].must_equal "005" - result[:args].count.must_equal 16 - result[:args][0].must_equal "CPAN" - result[:args][15].must_equal "are available on this server" + line.prefix.must_equal "pretend.dancer.server" + line.command.must_equal "005" + line.args.count.must_equal 16 + line.args[0].must_equal "CPAN" + line.args[15].must_equal "are available on this server" parser.isupport["MODES"].must_equal 4 end it 'should allow trailing \r\n or \n' do raw_rn = "#{raw_line}\x0d\x0a" - parser.decompose_line(raw_rn)[:command].must_equal "005" + parser.decompose(raw_rn)[:command].must_equal "005" raw_n = "#{raw_line}\x0a" - parser.decompose_line(raw_n)[:command].must_equal "005" + parser.decompose(raw_n)[:command].must_equal "005" end it "should compose the server message" do - irc_line = parser.compose_line(result) + irc_line = parser.compose(line) irc_line.must_equal raw_line end it "should fail to compose the server message" do - proc { parser.compose_line({}) }.must_raise ArgumentError - proc { parser.compose_line({ prefix: "foo" }) }.must_raise ArgumentError - proc { parser.compose_line({ command: "bar", args: ['a b', 'c'] }) }.must_raise ArgumentError + proc { parser.compose(IRCSupport::Line.new) }.must_raise ArgumentError + proc { parser.compose(IRCSupport::Line.new("foo")) }.must_raise ArgumentError + proc { parser.compose(IRCSupport::Line.new(nil, "bar", ['a b', 'c'])) }.must_raise ArgumentError end it "should fail to decompoes the IRC line" do @@ -55,7 +55,7 @@ end it "should handle unbalanced NULs" do - unbalanced = ":literal!hinrik@w.nix.is PRIVMSG #foo4321 :\x01ACTIOn jumps\x01foo\x01" + unbalanced = ":literal!hinrik@w.nix.is PRIVMSG #foo4321 :\x01ACTION jumps\x01foo\x01" msg = parser.parse(unbalanced) msg.message.must_equal 'jumps' end