X-Git-Url: https://git.netwichtig.de/gitweb/?a=blobdiff_plain;f=lib%2Frbot%2Fircbot.rb;h=bd19e6375e3b4021703c3c25a678714b9e083189;hb=edd1cf77be07ae507014574141e920ad23eb164d;hp=4be0c44475179d32ce737f80ec6a50cd361ccb55;hpb=2f909788778208bac970faa2128949bcb3bc6c01;p=user%2Fhenk%2Fcode%2Fruby%2Frbot.git diff --git a/lib/rbot/ircbot.rb b/lib/rbot/ircbot.rb index 4be0c444..bd19e637 100644 --- a/lib/rbot/ircbot.rb +++ b/lib/rbot/ircbot.rb @@ -69,7 +69,7 @@ $interrupted = 0 # these first require 'rbot/rbotconfig' require 'rbot/config' -require 'rbot/utils' +# require 'rbot/utils' require 'rbot/irc' require 'rbot/rfc2812' @@ -82,7 +82,7 @@ require 'rbot/message' require 'rbot/language' require 'rbot/dbhash' require 'rbot/registry' -require 'rbot/httputil' +# require 'rbot/httputil' module Irc @@ -154,6 +154,9 @@ class IrcBot :default => 6667, :type => :integer, :requires_restart => true, :desc => "What port should the bot connect to?", :validate => Proc.new {|v| v > 0}, :wizard => true) + BotConfig.register BotConfigBooleanValue.new('server.ssl', + :default => false, :requires_restart => true, :wizard => true, + :desc => "Use SSL to connect to this server?") BotConfig.register BotConfigStringValue.new('server.password', :default => false, :requires_restart => true, :desc => "Password for connecting to this server (if required)", @@ -175,7 +178,6 @@ class IrcBot :on_change => Proc.new {|bot, v| bot.socket.sendq_burst = v }) BotConfig.register BotConfigIntegerValue.new('server.ping_timeout', :default => 30, :validate => Proc.new{|v| v >= 0}, - :on_change => Proc.new {|bot, v| bot.start_server_pings}, :desc => "reconnect if server doesn't respond to PING within this many seconds (set to 0 to disable)") BotConfig.register BotConfigStringValue.new('irc.nick', :default => "rbot", @@ -193,7 +195,21 @@ class IrcBot BotConfig.register BotConfigIntegerValue.new('core.save_every', :default => 60, :validate => Proc.new{|v| v >= 0}, - # TODO change timer via on_change proc + :on_change => Proc.new { |bot, v| + if @save_timer + if v > 0 + @timer.reschedule(@save_timer, v) + @timer.unblock(@save_timer) + else + @timer.block(@save_timer) + end + else + if v > 0 + @save_timer = @timer.add(v) { bot.save } + end + # Nothing to do when v == 0 + end + }, :desc => "How often the bot should persist all configuration to disk (in case of a server crash, for example)") BotConfig.register BotConfigBooleanValue.new('core.run_as_daemon', @@ -219,6 +235,50 @@ class IrcBot :validate => Proc.new { |v| v > 0 }, :desc => "Maximum console messages logfile size (in megabytes)") + BotConfig.register BotConfigEnumValue.new('send.newlines', + :values => ['split', 'join'], :default => 'split', + :on_change => Proc.new { |bot, v| + bot.set_default_send_options :newlines => v.to_sym + }, + :desc => "When set to split, messages with embedded newlines will be sent as separate lines. When set to join, newlines will be replaced by the value of join_with") + BotConfig.register BotConfigStringValue.new('send.join_with', + :default => ' ', + :on_change => Proc.new { |bot, v| + bot.set_default_send_options :join_with => v.dup + }, + :desc => "String used to replace newlines when send.newlines is set to join") + BotConfig.register BotConfigIntegerValue.new('send.max_lines', + :default => 0, + :validate => Proc.new { |v| v >= 0 }, + :on_change => Proc.new { |bot, v| + bot.set_default_send_options :max_lines => v + }, + :desc => "Maximum number of IRC lines to send for each message (set to 0 for no limit)") + BotConfig.register BotConfigEnumValue.new('send.overlong', + :values => ['split', 'truncate'], :default => 'split', + :on_change => Proc.new { |bot, v| + bot.set_default_send_options :overlong => v.to_sym + }, + :desc => "When set to split, messages which are too long to fit in a single IRC line are split into multiple lines. When set to truncate, long messages are truncated to fit the IRC line length") + BotConfig.register BotConfigStringValue.new('send.split_at', + :default => '\s+', + :on_change => Proc.new { |bot, v| + bot.set_default_send_options :split_at => Regexp.new(v) + }, + :desc => "A regular expression that should match the split points for overlong messages (see send.overlong)") + BotConfig.register BotConfigBooleanValue.new('send.purge_split', + :default => true, + :on_change => Proc.new { |bot, v| + bot.set_default_send_options :purge_split => v + }, + :desc => "Set to true if the splitting boundary (set in send.split_at) should be removed when splitting overlong messages (see send.overlong)") + BotConfig.register BotConfigStringValue.new('send.truncate_text', + :default => "#{Reverse}...#{Reverse}", + :on_change => Proc.new { |bot, v| + bot.set_default_send_options :truncate_text => v.dup + }, + :desc => "When truncating overlong messages (see send.overlong) or when sending too many lines per message (see send.max_lines) replace the end of the last line with this text") + @argv = params[:argv] unless FileTest.directory? Config::coredir @@ -263,11 +323,12 @@ class IrcBot Dir.mkdir("#{botclass}/logs") unless File.exist?("#{botclass}/logs") Dir.mkdir("#{botclass}/registry") unless File.exist?("#{botclass}/registry") Dir.mkdir("#{botclass}/safe_save") unless File.exist?("#{botclass}/safe_save") - Utils.set_safe_save_dir("#{botclass}/safe_save") - @ping_timer = nil - @pong_timer = nil + # Time at which the last PING was sent @last_ping = nil + # Time at which the last line was RECV'd from the server + @last_rec = nil + @startup_time = Time.new begin @@ -312,7 +373,7 @@ class IrcBot end def STDOUT.write(str=nil) log str, 2 - return str.to_s.length + return str.to_s.size end def STDERR.write(str=nil) if str.to_s.match(/:\d+: warning:/) @@ -320,7 +381,7 @@ class IrcBot else error str, 2 end - return str.to_s.length + return str.to_s.size end end @@ -337,13 +398,15 @@ class IrcBot @timer = Timer::Timer.new(1.0) # only need per-second granularity @save_mutex = Mutex.new - @timer.add(@config['core.save_every']) { save } if @config['core.save_every'] + if @config['core.save_every'] > 0 + @save_timer = @timer.add(@config['core.save_every']) { save } + else + @save_timer = nil + end @quit_mutex = Mutex.new @logs = Hash.new - @httputil = Utils::HttpUtil.new(self) - @plugins = nil @lang = Language::Language.new(self, @config['core.language']) @@ -363,19 +426,24 @@ class IrcBot Dir.mkdir("#{botclass}/plugins") unless File.exist?("#{botclass}/plugins") @plugins = Plugins::pluginmanager @plugins.bot_associate(self) + @plugins.add_botmodule_dir(Config::coredir + "/utils") @plugins.add_botmodule_dir(Config::coredir) @plugins.add_botmodule_dir("#{botclass}/plugins") @plugins.add_botmodule_dir(Config::datadir + "/plugins") @plugins.scan - @socket = IrcSocket.new(@config['server.name'], @config['server.port'], @config['server.bindhost'], @config['server.sendq_delay'], @config['server.sendq_burst']) + Utils.set_safe_save_dir("#{botclass}/safe_save") + @httputil = Utils::HttpUtil.new(self) + + + @socket = IrcSocket.new(@config['server.name'], @config['server.port'], @config['server.bindhost'], @config['server.sendq_delay'], @config['server.sendq_burst'], :ssl => @config['server.ssl']) @client = IrcClient.new - myself.nick = @config['irc.nick'] # Channels where we are quiet - # It's nil when we are not quiet, an empty list when we are quiet - # in all channels, a list of channels otherwise - @quiet = nil + # Array of channels names where the bot should be quiet + # '*' means all channels + # + @quiet = [] @client[:welcome] = proc {|data| irclog "joined server #{@client.server} as #{myself}", "server" @@ -391,33 +459,48 @@ class IrcBot end } } + + # TODO the next two @client should go into rfc2812.rb, probably + # Since capabs are two-steps processes, server.supports[:capab] + # should be a three-state: nil, [], [....] + asked_for = { :"identify-msg" => false } @client[:isupport] = proc { |data| - # TODO this needs to go into rfc2812.rb - # Since capabs are two-steps processes, server.supports[:capab] - # should be a three-state: nil, [], [....] - sendq "CAPAB IDENTIFY-MSG" if server.supports[:capab] + if server.supports[:capab] and !asked_for[:"identify-msg"] + sendq "CAPAB IDENTIFY-MSG" + asked_for[:"identify-msg"] = true + end } @client[:datastr] = proc { |data| - # TODO this needs to go into rfc2812.rb if data[:text] == "IDENTIFY-MSG" - server.capabilities["identify-msg".to_sym] = true + server.capabilities[:"identify-msg"] = true else debug "Not handling RPL_DATASTR #{data[:servermessage]}" end } + @client[:privmsg] = proc { |data| m = PrivMessage.new(self, server, data[:source], data[:target], data[:message]) # debug "Message source is #{data[:source].inspect}" # debug "Message target is #{data[:target].inspect}" # debug "Bot is #{myself.inspect}" - # TODO use the new Netmask class - # @config['irc.ignore_users'].each { |mask| return if Irc.netmaskmatch(mask,m.source) } + ignored = false + @config['irc.ignore_users'].each { |mask| + if m.source.matches?(server.new_netmask(mask)) + ignored = true + break + end + } irclogprivmsg(m) - @plugins.delegate "listen", m - @plugins.privmsg(m) if m.address? + unless ignored + @plugins.delegate "listen", m + @plugins.privmsg(m) if m.address? + if not m.replied + @plugins.delegate "unreplied", m + end + end } @client[:notice] = proc { |data| message = NoticeMessage.new(self, server, data[:source], data[:target], data[:message]) @@ -430,11 +513,19 @@ class IrcBot } } @client[:nicktaken] = proc { |data| - nickchg "#{data[:nick]}_" + new = "#{data[:nick]}_" + nickchg new + # If we're setting our nick at connection because our choice was taken, + # we have to fix our nick manually, because there will be no NICK message + # yo inform us that our nick has been changed. + if data[:target] == '*' + debug "setting my connection nick to #{new}" + nick = new + end @plugins.delegate "nicktaken", data[:nick] } @client[:badnick] = proc {|data| - warning "bad nick (#{data[:nick]})" + arning "bad nick (#{data[:nick]})" } @client[:ping] = proc {|data| sendq "PONG #{data[:pingid]}" @@ -451,7 +542,7 @@ class IrcBot debug "my nick is now #{new}" end data[:is_on].each { |ch| - irclog "@ #{old} is now known as #{new}", ch + irclog "@ #{old} is now known as #{new}", ch } @plugins.delegate("listen", m) @plugins.delegate("nick", m) @@ -521,28 +612,55 @@ class IrcBot #debug "UNKNOWN: #{data[:serverstring]}" irclog data[:serverstring], ".unknown" } + + set_default_send_options :newlines => @config['send.newlines'].to_sym, + :join_with => @config['send.join_with'].dup, + :max_lines => @config['send.max_lines'], + :overlong => @config['send.overlong'].to_sym, + :split_at => Regexp.new(@config['send.split_at']), + :purge_split => @config['send.purge_split'], + :truncate_text => @config['send.truncate_text'].dup end + def set_default_send_options(opts={}) + # Default send options for NOTICE and PRIVMSG + unless defined? @default_send_options + @default_send_options = { + :queue_channel => nil, # use default queue channel + :queue_ring => nil, # use default queue ring + :newlines => :split, # or :join + :join_with => ' ', # by default, use a single space + :max_lines => 0, # maximum number of lines to send with a single command + :overlong => :split, # or :truncate + # TODO an array of splitpoints would be preferrable for this option: + :split_at => /\s+/, # by default, split overlong lines at whitespace + :purge_split => true, # should the split string be removed? + :truncate_text => "#{Reverse}...#{Reverse}" # text to be appened when truncating + } + end + @default_send_options.update opts unless opts.empty? + end + # checks if we should be quiet on a channel def quiet_on?(channel) - return false unless @quiet - return true if @quiet.empty? - return @quiet.include?(channel.to_s) + return @quiet.include?('*') || @quiet.include?(channel.downcase) end - def set_quiet(channel=nil) + def set_quiet(channel) if channel - @quiet << channel.to_s unless @quiet.include?(channel.to_s) + ch = channel.downcase.dup + @quiet << ch unless @quiet.include?(ch) else - @quiet = [] + @quiet.clear + @quiet << '*' end end - def reset_quiet(channel=nil) + def reset_quiet(channel) if channel - @quiet.delete_if { |x| x == channel.to_s } + @quiet.delete channel.downcase else - @quiet = nil + @quiet.clear end end @@ -578,24 +696,33 @@ class IrcBot end quit if $interrupted > 0 @socket.emergency_puts "PASS " + @config['server.password'] if @config['server.password'] - @socket.emergency_puts "NICK #{@config['irc.nick']}\nUSER #{@config['irc.user']} 4 #{@config['server.name']} :Ruby bot. (c) Tom Gilbert" + @socket.emergency_puts "NICK #{@config['irc.nick']}\nUSER #{@config['irc.user']} 4 #{@config['server.name']} :Ruby bot. (c) Tom Gilbert and the rbot development team" quit if $interrupted > 0 - start_server_pings + myself.nick = @config['irc.nick'] + myself.user = @config['irc.user'] end # begin event handling loop def mainloop while true begin - quit if $interrupted > 0 + quit if $interrupted > 0 connect @timer.start while @socket.connected? - quit if $interrupted > 0 - if @socket.select + quit if $interrupted > 0 + + # Wait for messages and process them as they arrive. If nothing is + # received, we call the ping_server() method that will PING the + # server if appropriate, or raise a TimeoutError if no PONG has been + # received in the user-chosen timeout since the last PING sent. + if @socket.select(1) break unless reply = @socket.gets + @last_rec = Time.now @client.process reply + else + ping_server end end @@ -649,103 +776,158 @@ class IrcBot # Type can be PRIVMSG, NOTICE, etc, but those you should really use the # relevant say() or notice() methods. This one should be used for IRCd # extensions you want to use in modules. - def sendmsg(type, where, message, chan=nil, ring=0) - # Split the message so that each line sent is not longher than 400 bytes - # TODO allow something to do for commands that produce too many messages - # TODO example: math 10**10000 - # TODO try to use the maximum line length allowed by the server, if there is - # a way to know what it is - left = 400 - type.length - where.to_s.length - 3 - begin - if(left >= message.length) - sendq "#{type} #{where} :#{message}", chan, ring - log_sent(type, where, message) - return - end - line = message.slice!(0, left) - lastspace = line.rindex(/\s+/) - if(lastspace) - message = line.slice!(lastspace, line.length) + message - message.gsub!(/^\s+/, "") - end - sendq "#{type} #{where} :#{line}", chan, ring - log_sent(type, where, line) - end while(message.length > 0) - end + def sendmsg(type, where, original_message, options={}) + opts = @default_send_options.merge(options) - # queue an arbitraty message for the server - def sendq(message="", chan=nil, ring=0) - # temporary - @socket.queue(message, chan, ring) - end - - # send a notice message to channel/nick +where+ - def notice(where, message, mchan="", mring=-1) - if mchan == "" - chan = where - else + # For starters, set up appropriate queue channels and rings + mchan = opts[:queue_channel] + mring = opts[:queue_ring] + if mchan chan = mchan + else + chan = where end - if mring < 0 + if mring + ring = mring + else case where when User ring = 1 else ring = 2 end - else - ring = mring end - message.each_line { |line| - line.chomp! - next unless(line.length > 0) - sendmsg "NOTICE", where, line, chan, ring - } - end - # say something (PRIVMSG) to channel/nick +where+ - def say(where, message, mchan="", mring=-1) - if mchan == "" - chan = where + message = original_message.to_s.gsub(/[\r\n]+/, "\n") + case opts[:newlines] + when :join + lines = [message.gsub("\n", opts[:join_with])] + when :split + lines = Array.new + message.each_line { |line| + line.chomp! + next unless(line.size > 0) + lines << line + } else - chan = mchan + raise "Unknown :newlines option #{opts[:newlines]} while sending #{original_message.inspect}" end - if mring < 0 - case where - when User - ring = 1 - else - ring = 2 - end + + # The IRC protocol requires that each raw message must be not longer + # than 512 characters. From this length with have to subtract the EOL + # terminators (CR+LF) and the length of ":botnick!botuser@bothost " + # that will be prepended by the server to all of our messages. + + # The maximum raw message length we can send is therefore 512 - 2 - 2 + # minus the length of our hostmask. + + max_len = 508 - myself.fullform.size + + # On servers that support IDENTIFY-MSG, we have to subtract 1, because messages + # will have a + or - prepended + if server.capabilities[:"identify-msg"] + max_len -= 1 + end + + # When splitting the message, we'll be prefixing the following string: + # (e.g. "PRIVMSG #rbot :") + fixed = "#{type} #{where} :" + + # And this is what's left + left = max_len - fixed.size + + case opts[:overlong] + when :split + truncate = false + split_at = opts[:split_at] + when :truncate + truncate = opts[:truncate_text] + truncate = @default_send_options[:truncate_text] if truncate.size > left + truncate = "" if truncate.size > left else - ring = mring + raise "Unknown :overlong option #{opts[:overlong]} while sending #{original_message.inspect}" end - message.to_s.gsub(/[\r\n]+/, "\n").each_line { |line| - line.chomp! - next unless(line.length > 0) - unless quiet_on?(where) - sendmsg "PRIVMSG", where, line, chan, ring - end + + # Counter to check the number of lines sent by this command + cmd_lines = 0 + max_lines = opts[:max_lines] + maxed = false + line = String.new + lines.each { |msg| + begin + if max_lines > 0 and cmd_lines == max_lines - 1 + truncate = opts[:truncate_text] + truncate = @default_send_options[:truncate_text] if truncate.size > left + truncate = "" if truncate.size > left + maxed = true + end + if(left >= msg.size) and not maxed + sendq "#{fixed}#{msg}", chan, ring + log_sent(type, where, msg) + cmd_lines += 1 + break + end + if truncate + line.replace msg.slice(0, left-truncate.size) + # line.sub!(/\s+\S*$/, truncate) + line << truncate + raise "PROGRAMMER ERROR! #{line.inspect} of size #{line.size} > #{left}" if line.size > left + sendq "#{fixed}#{line}", chan, ring + log_sent(type, where, line) + return + end + line.replace msg.slice!(0, left) + lastspace = line.rindex(opts[:split_at]) + if(lastspace) + msg.replace line.slice!(lastspace, line.size) + msg + msg.gsub!(/^#{opts[:split_at]}/, "") if opts[:purge_split] + end + sendq "#{fixed}#{line}", chan, ring + log_sent(type, where, line) + cmd_lines += 1 + end while(msg.size > 0) } end + # queue an arbitraty message for the server + def sendq(message="", chan=nil, ring=0) + # temporary + @socket.queue(message, chan, ring) + end + + # send a notice message to channel/nick +where+ + def notice(where, message, options={}) + return if where.kind_of?(Channel) and quiet_on?(where) + sendmsg "NOTICE", where, message, options + end + + # say something (PRIVMSG) to channel/nick +where+ + def say(where, message, options={}) + return if where.kind_of?(Channel) and quiet_on?(where) + sendmsg "PRIVMSG", where, message, options + end + # perform a CTCP action with message +message+ to channel/nick +where+ - def action(where, message, mchan="", mring=-1) - if mchan == "" - chan = where - else + def action(where, message, options={}) + return if where.kind_of?(Channel) and quiet_on?(where) + mchan = options.fetch(:queue_channel, nil) + mring = options.fetch(:queue_ring, nil) + if mchan chan = mchan + else + chan = where end - if mring < 0 + if mring + ring = mring + else case where - when Channel - ring = 2 - else + when User ring = 1 + else + ring = 2 end - else - ring = mring end + # FIXME doesn't check message length. Can we make this exploit sendmsg? sendq "PRIVMSG #{where} :\001ACTION #{message}\001", chan, ring case where when Channel @@ -765,7 +947,7 @@ class IrcBot def irclog(message, where="server") message = message.chomp stamp = Time.now.strftime("%Y/%m/%d %H:%M:%S") - where = where.to_s.gsub(/[:!?$*()\/\\<>|"']/, "_") + where = where.downcase.gsub(/[:!?$*()\/\\<>|"']/, "_") unless(@logs.has_key?(where)) @logs[where] = File.new("#{@botclass}/logs/#{where}", "a") @logs[where].sync = true @@ -874,17 +1056,17 @@ class IrcBot # attempt to change bot's nick to +name+ def nickchg(name) - sendq "NICK #{name}" + sendq "NICK #{name}" end # changing mode def mode(channel, mode, target) - sendq "MODE #{channel} #{mode} #{target}", channel, 2 + sendq "MODE #{channel} #{mode} #{target}", channel, 2 end # kicking a user def kick(channel, user, msg) - sendq "KICK #{channel} #{user} :#{msg}", channel, 2 + sendq "KICK #{channel} #{user} :#{msg}", channel, 2 end # m:: message asking for help @@ -913,44 +1095,34 @@ class IrcBot return "Uptime #{uptime}, #{@plugins.length} plugins active, #{@socket.lines_sent} lines sent, #{@socket.lines_received} received." end - # we'll ping the server every 30 seconds or so, and expect a response - # before the next one come around.. - def start_server_pings - stop_server_pings - return unless @config['server.ping_timeout'] > 0 - # we want to respond to a hung server within 30 secs or so - @ping_timer = @timer.add(30) { - @last_ping = Time.now - @socket.queue "PING :rbot" - } - @pong_timer = @timer.add(10) { - unless @last_ping.nil? - diff = Time.now - @last_ping - unless diff < @config['server.ping_timeout'] - debug "no PONG from server for #{diff} seconds, reconnecting" - begin - @socket.shutdown - rescue - debug "couldn't shutdown connection (already shutdown?)" - end - @last_ping = nil + # We want to respond to a hung server in a timely manner. If nothing was received + # in the user-selected timeout and we haven't PINGed the server yet, we PING + # the server. If the PONG is not received within the user-defined timeout, we + # assume we're in ping timeout and act accordingly. + def ping_server + act_timeout = @config['server.ping_timeout'] + return if act_timeout <= 0 + now = Time.now + if @last_rec && now > @last_rec + act_timeout + if @last_ping.nil? + # No previous PING pending, send a new one + sendq "PING :rbot" + @last_ping = Time.now + else + diff = now - @last_ping + if diff > act_timeout + debug "no PONG from server in #{diff} seconds, reconnecting" + # the actual reconnect is handled in the main loop: raise TimeoutError, "no PONG from server in #{diff} seconds" end end - } + end end def stop_server_pings + # cancel previous PINGs and reset time of last RECV @last_ping = nil - # stop existing timers if running - unless @ping_timer.nil? - @timer.remove @ping_timer - @ping_timer = nil - end - unless @pong_timer.nil? - @timer.remove @pong_timer - @pong_timer = nil - end + @last_rec = nil end private @@ -979,7 +1151,7 @@ class IrcBot when Channel irclog "-=#{myself}=- #{message}", where else - irclog "[-=#{where}=-] #{message}", where + irclog "[-=#{where}=-] #{message}", where end when "PRIVMSG" case where