]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - lib/rbot/ircbot.rb
6d76cc4962795f76baf9c3f24986c195736ff843
[user/henk/code/ruby/rbot.git] / lib / rbot / ircbot.rb
1 require 'thread'
2 require 'etc'
3 require 'fileutils'
4
5 $debug = false unless $debug
6 $daemonize = false unless $daemonize
7
8 def rawlog(code="", message=nil)
9   if !code || code.empty?
10     c = "  "
11   else
12     c = code.to_s[0,1].upcase + ":"
13   end
14   stamp = Time.now.strftime("%Y/%m/%d %H:%M:%S")
15   message.to_s.each_line { |l|
16     $stdout.puts "#{c} [#{stamp}] #{l}"
17   }
18   $stdout.flush
19 end
20
21 def log(message=nil)
22   rawlog("", message)
23 end
24
25 def log_session_end
26    log("\n=== #{botclass} session ended ===") if $daemonize
27 end
28
29 def debug(message=nil)
30   rawlog("D", message) if $debug
31 end
32
33 def warning(message=nil)
34   rawlog("W", message)
35 end
36
37 def error(message=nil)
38   rawlog("E", message)
39 end
40
41 # The following global is used for the improved signal handling.
42 $interrupted = 0
43
44 # these first
45 require 'rbot/rbotconfig'
46 require 'rbot/config'
47 require 'rbot/utils'
48
49 require 'rbot/rfc2812'
50 require 'rbot/keywords'
51 require 'rbot/ircsocket'
52 require 'rbot/auth'
53 require 'rbot/timer'
54 require 'rbot/plugins'
55 require 'rbot/channel'
56 require 'rbot/message'
57 require 'rbot/language'
58 require 'rbot/dbhash'
59 require 'rbot/registry'
60 require 'rbot/httputil'
61
62 module Irc
63
64 # Main bot class, which manages the various components, receives messages,
65 # handles them or passes them to plugins, and contains core functionality.
66 class IrcBot
67   # the bot's current nickname
68   attr_reader :nick
69
70   # the bot's IrcAuth data
71   attr_reader :auth
72
73   # the bot's BotConfig data
74   attr_reader :config
75
76   # the botclass for this bot (determines configdir among other things)
77   attr_reader :botclass
78
79   # used to perform actions periodically (saves configuration once per minute
80   # by default)
81   attr_reader :timer
82
83   # bot's Language data
84   attr_reader :lang
85
86   # channel info for channels the bot is in
87   attr_reader :channels
88
89   # bot's irc socket
90   attr_reader :socket
91
92   # bot's object registry, plugins get an interface to this for persistant
93   # storage (hash interface tied to a bdb file, plugins use Accessors to store
94   # and restore objects in their own namespaces.)
95   attr_reader :registry
96
97   # bot's httputil help object, for fetching resources via http. Sets up
98   # proxies etc as defined by the bot configuration/environment
99   attr_reader :httputil
100
101   # create a new IrcBot with botclass +botclass+
102   def initialize(botclass, params = {})
103     # BotConfig for the core bot
104     # TODO should we split socket stuff into ircsocket, etc?
105     BotConfig.register BotConfigStringValue.new('server.name',
106       :default => "localhost", :requires_restart => true,
107       :desc => "What server should the bot connect to?",
108       :wizard => true)
109     BotConfig.register BotConfigIntegerValue.new('server.port',
110       :default => 6667, :type => :integer, :requires_restart => true,
111       :desc => "What port should the bot connect to?",
112       :validate => Proc.new {|v| v > 0}, :wizard => true)
113     BotConfig.register BotConfigStringValue.new('server.password',
114       :default => false, :requires_restart => true,
115       :desc => "Password for connecting to this server (if required)",
116       :wizard => true)
117     BotConfig.register BotConfigStringValue.new('server.bindhost',
118       :default => false, :requires_restart => true,
119       :desc => "Specific local host or IP for the bot to bind to (if required)",
120       :wizard => true)
121     BotConfig.register BotConfigIntegerValue.new('server.reconnect_wait',
122       :default => 5, :validate => Proc.new{|v| v >= 0},
123       :desc => "Seconds to wait before attempting to reconnect, on disconnect")
124     BotConfig.register BotConfigFloatValue.new('server.sendq_delay',
125       :default => 2.0, :validate => Proc.new{|v| v >= 0},
126       :desc => "(flood prevention) the delay between sending messages to the server (in seconds)",
127       :on_change => Proc.new {|bot, v| bot.socket.sendq_delay = v })
128     BotConfig.register BotConfigIntegerValue.new('server.sendq_burst',
129       :default => 4, :validate => Proc.new{|v| v >= 0},
130       :desc => "(flood prevention) max lines to burst to the server before throttling. Most ircd's allow bursts of up 5 lines",
131       :on_change => Proc.new {|bot, v| bot.socket.sendq_burst = v })
132     BotConfig.register BotConfigStringValue.new('server.byterate',
133       :default => "400/2", :validate => Proc.new{|v| v.match(/\d+\/\d/)},
134       :desc => "(flood prevention) max bytes/seconds rate to send the server. Most ircd's have limits of 512 bytes/2 seconds",
135       :on_change => Proc.new {|bot, v| bot.socket.byterate = v })
136     BotConfig.register BotConfigIntegerValue.new('server.ping_timeout',
137       :default => 10, :validate => Proc.new{|v| v >= 0},
138       :on_change => Proc.new {|bot, v| bot.start_server_pings},
139       :desc => "reconnect if server doesn't respond to PING within this many seconds (set to 0 to disable)")
140
141     BotConfig.register BotConfigStringValue.new('irc.nick', :default => "rbot",
142       :desc => "IRC nickname the bot should attempt to use", :wizard => true,
143       :on_change => Proc.new{|bot, v| bot.sendq "NICK #{v}" })
144     BotConfig.register BotConfigStringValue.new('irc.user', :default => "rbot",
145       :requires_restart => true,
146       :desc => "local user the bot should appear to be", :wizard => true)
147     BotConfig.register BotConfigArrayValue.new('irc.join_channels',
148       :default => [], :wizard => true,
149       :desc => "What channels the bot should always join at startup. List multiple channels using commas to separate. If a channel requires a password, use a space after the channel name. e.g: '#chan1, #chan2, #secretchan secritpass, #chan3'")
150     BotConfig.register BotConfigArrayValue.new('irc.ignore_users',
151       :default => [], 
152       :desc => "Which users to ignore input from. This is mainly to avoid bot-wars triggered by creative people")
153
154     BotConfig.register BotConfigIntegerValue.new('core.save_every',
155       :default => 60, :validate => Proc.new{|v| v >= 0},
156       # TODO change timer via on_change proc
157       :desc => "How often the bot should persist all configuration to disk (in case of a server crash, for example)")
158       # BotConfig.register BotConfigBooleanValue.new('core.debug',
159       #   :default => false, :requires_restart => true,
160       #   :on_change => Proc.new { |v|
161       #     debug ((v ? "Enabling" : "Disabling") + " debug output.")
162       #     $debug = v
163       #     debug (($debug ? "Enabled" : "Disabled") + " debug output.")
164       #   },
165       #   :desc => "Should the bot produce debug output?")
166     BotConfig.register BotConfigBooleanValue.new('core.run_as_daemon',
167       :default => false, :requires_restart => true,
168       :desc => "Should the bot run as a daemon?")
169     BotConfig.register BotConfigStringValue.new('core.logfile',
170       :default => false, :requires_restart => true,
171       :desc => "Name of the logfile to which console messages will be redirected when the bot is run as a daemon")
172
173     @argv = params[:argv]
174
175     unless FileTest.directory? Config::datadir
176       error "data directory '#{Config::datadir}' not found, did you setup.rb?"
177       exit 2
178     end
179
180     botclass = "#{Etc.getpwuid(Process::Sys.geteuid)[:dir]}/.rbot" unless botclass
181     #botclass = "#{ENV['HOME']}/.rbot" unless botclass
182     botclass = File.expand_path(botclass)
183     @botclass = botclass.gsub(/\/$/, "")
184
185     unless FileTest.directory? botclass
186       log "no #{botclass} directory found, creating from templates.."
187       if FileTest.exist? botclass
188         error "file #{botclass} exists but isn't a directory"
189         exit 2
190       end
191       FileUtils.cp_r Config::datadir+'/templates', botclass
192     end
193
194     Dir.mkdir("#{botclass}/logs") unless File.exist?("#{botclass}/logs")
195     Dir.mkdir("#{botclass}/registry") unless File.exist?("#{botclass}/registry")
196
197     @ping_timer = nil
198     @pong_timer = nil
199     @last_ping = nil
200     @startup_time = Time.new
201     @config = BotConfig.new(self)
202     # background self after botconfig has a chance to run wizard
203     @logfile = @config['core.logfile']
204     if @logfile.class!=String || @logfile.empty?
205       @logfile = File.basename(botclass)+".log"
206     end
207     if @config['core.run_as_daemon']
208       $daemonize = true
209     end
210     # See http://blog.humlab.umu.se/samuel/archives/000107.html
211     # for the backgrounding code 
212     if $daemonize
213       begin
214         exit if fork
215         Process.setsid
216         exit if fork
217       rescue NotImplementedError
218         warning "Could not background, fork not supported"
219       rescue => e
220         warning "Could not background. #{e.inspect}"
221       end
222       Dir.chdir botclass
223       # File.umask 0000                # Ensure sensible umask. Adjust as needed.
224       log "Redirecting standard input/output/error"
225       begin
226         STDIN.reopen "/dev/null"
227       rescue Errno::ENOENT
228         # On Windows, there's not such thing as /dev/null
229         STDIN.reopen "NUL"
230       end
231       STDOUT.reopen @logfile, "a"
232       STDERR.reopen STDOUT
233       log "\n=== #{botclass} session started ==="
234     end
235
236     @timer = Timer::Timer.new(1.0) # only need per-second granularity
237     @registry = BotRegistry.new self
238     @timer.add(@config['core.save_every']) { save } if @config['core.save_every']
239     @channels = Hash.new
240     @logs = Hash.new
241     @httputil = Utils::HttpUtil.new(self)
242     @lang = Language::Language.new(@config['core.language'])
243     @keywords = Keywords.new(self)
244     @auth = IrcAuth.new(self)
245
246     Dir.mkdir("#{botclass}/plugins") unless File.exist?("#{botclass}/plugins")
247     @plugins = Plugins::Plugins.new(self, ["#{botclass}/plugins"])
248
249     @socket = IrcSocket.new(@config['server.name'], @config['server.port'], @config['server.bindhost'], @config['server.sendq_delay'], @config['server.sendq_burst'])
250     @nick = @config['irc.nick']
251
252     @client = IrcClient.new
253     @client[:privmsg] = proc { |data|
254       message = PrivMessage.new(self, data[:source], data[:target], data[:message])
255       onprivmsg(message)
256     }
257     @client[:notice] = proc { |data|
258       message = NoticeMessage.new(self, data[:source], data[:target], data[:message])
259       # pass it off to plugins that want to hear everything
260       @plugins.delegate "listen", message
261     }
262     @client[:motd] = proc { |data|
263       data[:motd].each_line { |line|
264         irclog "MOTD: #{line}", "server"
265       }
266     }
267     @client[:nicktaken] = proc { |data|
268       nickchg "#{data[:nick]}_"
269       @plugins.delegate "nicktaken", data[:nick]
270     }
271     @client[:badnick] = proc {|data|
272       warning "bad nick (#{data[:nick]})"
273     }
274     @client[:ping] = proc {|data|
275       # (jump the queue for pongs)
276       @socket.puts "PONG #{data[:pingid]}"
277     }
278     @client[:pong] = proc {|data|
279       @last_ping = nil
280     }
281     @client[:nick] = proc {|data|
282       sourcenick = data[:sourcenick]
283       nick = data[:nick]
284       m = NickMessage.new(self, data[:source], data[:sourcenick], data[:nick])
285       if(sourcenick == @nick)
286         debug "my nick is now #{nick}"
287         @nick = nick
288       end
289       @channels.each {|k,v|
290         if(v.users.has_key?(sourcenick))
291           irclog "@ #{sourcenick} is now known as #{nick}", k
292           v.users[nick] = v.users[sourcenick]
293           v.users.delete(sourcenick)
294         end
295       }
296       @plugins.delegate("listen", m)
297       @plugins.delegate("nick", m)
298     }
299     @client[:quit] = proc {|data|
300       source = data[:source]
301       sourcenick = data[:sourcenick]
302       sourceurl = data[:sourceaddress]
303       message = data[:message]
304       m = QuitMessage.new(self, data[:source], data[:sourcenick], data[:message])
305       if(data[:sourcenick] =~ /#{Regexp.escape(@nick)}/i)
306       else
307         @channels.each {|k,v|
308           if(v.users.has_key?(sourcenick))
309             irclog "@ Quit: #{sourcenick}: #{message}", k
310             v.users.delete(sourcenick)
311           end
312         }
313       end
314       @plugins.delegate("listen", m)
315       @plugins.delegate("quit", m)
316     }
317     @client[:mode] = proc {|data|
318       source = data[:source]
319       sourcenick = data[:sourcenick]
320       sourceurl = data[:sourceaddress]
321       channel = data[:channel]
322       targets = data[:targets]
323       modestring = data[:modestring]
324       irclog "@ Mode #{modestring} #{targets} by #{sourcenick}", channel
325     }
326     @client[:welcome] = proc {|data|
327       irclog "joined server #{data[:source]} as #{data[:nick]}", "server"
328       debug "I think my nick is #{@nick}, server thinks #{data[:nick]}"
329       if data[:nick] && data[:nick].length > 0
330         @nick = data[:nick]
331       end
332
333       @plugins.delegate("connect")
334
335       @config['irc.join_channels'].each {|c|
336         debug "autojoining channel #{c}"
337         if(c =~ /^(\S+)\s+(\S+)$/i)
338           join $1, $2
339         else
340           join c if(c)
341         end
342       }
343     }
344     @client[:join] = proc {|data|
345       m = JoinMessage.new(self, data[:source], data[:channel], data[:message])
346       onjoin(m)
347     }
348     @client[:part] = proc {|data|
349       m = PartMessage.new(self, data[:source], data[:channel], data[:message])
350       onpart(m)
351     }
352     @client[:kick] = proc {|data|
353       m = KickMessage.new(self, data[:source], data[:target],data[:channel],data[:message])
354       onkick(m)
355     }
356     @client[:invite] = proc {|data|
357       if(data[:target] =~ /^#{Regexp.escape(@nick)}$/i)
358         join data[:channel] if (@auth.allow?("join", data[:source], data[:sourcenick]))
359       end
360     }
361     @client[:changetopic] = proc {|data|
362       channel = data[:channel]
363       sourcenick = data[:sourcenick]
364       topic = data[:topic]
365       timestamp = data[:unixtime] || Time.now.to_i
366       if(sourcenick == @nick)
367         irclog "@ I set topic \"#{topic}\"", channel
368       else
369         irclog "@ #{sourcenick} set topic \"#{topic}\"", channel
370       end
371       m = TopicMessage.new(self, data[:source], data[:channel], timestamp, data[:topic])
372
373       ontopic(m)
374       @plugins.delegate("listen", m)
375       @plugins.delegate("topic", m)
376     }
377     @client[:topic] = @client[:topicinfo] = proc {|data|
378       channel = data[:channel]
379       m = TopicMessage.new(self, data[:source], data[:channel], data[:unixtime], data[:topic])
380         ontopic(m)
381     }
382     @client[:names] = proc {|data|
383       channel = data[:channel]
384       users = data[:users]
385       unless(@channels[channel])
386         warning "got names for channel '#{channel}' I didn't think I was in\n"
387         # exit 2
388       end
389       @channels[channel].users.clear
390       users.each {|u|
391         @channels[channel].users[u[0].sub(/^[@&~+]/, '')] = ["mode", u[1]]
392       }
393       @plugins.delegate "names", data[:channel], data[:users]
394     }
395     @client[:unknown] = proc {|data|
396       #debug "UNKNOWN: #{data[:serverstring]}"
397       irclog data[:serverstring], ".unknown"
398     }
399   end
400
401   def got_sig(sig)
402     debug "received #{sig}, queueing quit"
403     $interrupted += 1
404     debug "interrupted #{$interrupted} times"
405     if $interrupted >= 5
406       debug "drastic!"
407       log_session_end
408       exit 2
409     elsif $interrupted >= 3
410       debug "quitting"
411       quit
412     end
413   end
414
415   # connect the bot to IRC
416   def connect
417     begin
418       trap("SIGINT") { got_sig("SIGINT") }
419       trap("SIGTERM") { got_sig("SIGTERM") }
420       trap("SIGHUP") { got_sig("SIGHUP") }
421     rescue ArgumentError => e
422       debug "failed to trap signals (#{e.inspect}): running on Windows?"
423     rescue => e
424       debug "failed to trap signals: #{e.inspect}"
425     end
426     begin
427       @socket.connect unless $interrupted > 0
428     rescue => e
429       raise e.class, "failed to connect to IRC server at #{@config['server.name']} #{@config['server.port']}: " + e
430     end
431     @socket.puts "PASS " + @config['server.password'] if @config['server.password']
432     @socket.puts "NICK #{@nick}\nUSER #{@config['irc.user']} 4 #{@config['server.name']} :Ruby bot. (c) Tom Gilbert"
433     start_server_pings
434   end
435
436   # begin event handling loop
437   def mainloop
438     while true
439       begin
440         connect
441         @timer.start
442
443         while @socket.connected?
444           if @socket.select
445             break unless reply = @socket.gets
446             @client.process reply
447           end
448           quit if $interrupted > 0
449         end
450
451       # I despair of this. Some of my users get "connection reset by peer"
452       # exceptions that ARENT SocketError's. How am I supposed to handle
453       # that?
454       rescue SystemExit
455         log_session_end
456         exit 0
457       rescue TimeoutError, SocketError => e
458         error "network exception: #{e.class}: #{e}"
459         debug e.inspect
460         debug e.backtrace.join("\n")
461       rescue BDB::Fatal => e
462         error "fatal bdb error: #{e.class}: #{e}"
463         error e.inspect
464         error e.backtrace.join("\n")
465         DBTree.stats
466         restart("Oops, we seem to have registry problems ...")
467       rescue Exception => e
468         error "non-net exception: #{e.class}: #{e}"
469         error e.inspect
470         error e.backtrace.join("\n")
471         @socket.shutdown # now we reconnect
472       rescue => e
473         error "unexpected exception: #{e.class}: #{e}"
474         error e.inspect
475         error e.backtrace.join("\n")
476         log_session_end
477         exit 2
478       end
479
480       log "disconnected"
481
482       stop_server_pings
483       @channels.clear
484       @socket.clearq
485
486       log "waiting to reconnect"
487       sleep @config['server.reconnect_wait']
488     end
489   end
490
491   # type:: message type
492   # where:: message target
493   # message:: message text
494   # send message +message+ of type +type+ to target +where+
495   # Type can be PRIVMSG, NOTICE, etc, but those you should really use the
496   # relevant say() or notice() methods. This one should be used for IRCd
497   # extensions you want to use in modules.
498   def sendmsg(type, where, message)
499     # limit it according to the byterate, splitting the message
500     # taking into consideration the actual message length
501     # and all the extra stuff
502     # TODO allow something to do for commands that produce too many messages
503     # TODO example: math 10**10000
504     left = @socket.bytes_per - type.length - where.length - 4
505     begin
506       if(left >= message.length)
507         sendq("#{type} #{where} :#{message}")
508         log_sent(type, where, message)
509         return
510       end
511       line = message.slice!(0, left)
512       lastspace = line.rindex(/\s+/)
513       if(lastspace)
514         message = line.slice!(lastspace, line.length) + message
515         message.gsub!(/^\s+/, "")
516       end
517       sendq("#{type} #{where} :#{line}")
518       log_sent(type, where, line)
519     end while(message.length > 0)
520   end
521
522   # queue an arbitraty message for the server
523   def sendq(message="")
524     # temporary
525     @socket.queue(message)
526   end
527
528   # send a notice message to channel/nick +where+
529   def notice(where, message)
530     message.each_line { |line|
531       line.chomp!
532       next unless(line.length > 0)
533       sendmsg("NOTICE", where, line)
534     }
535   end
536
537   # say something (PRIVMSG) to channel/nick +where+
538   def say(where, message)
539     message.to_s.gsub(/[\r\n]+/, "\n").each_line { |line|
540       line.chomp!
541       next unless(line.length > 0)
542       unless((where =~ /^#/) && (@channels.has_key?(where) && @channels[where].quiet))
543         sendmsg("PRIVMSG", where, line)
544       end
545     }
546   end
547
548   # perform a CTCP action with message +message+ to channel/nick +where+
549   def action(where, message)
550     sendq("PRIVMSG #{where} :\001ACTION #{message}\001")
551     if(where =~ /^#/)
552       irclog "* #{@nick} #{message}", where
553     elsif (where =~ /^(\S*)!.*$/)
554          irclog "* #{@nick}[#{where}] #{message}", $1
555     else
556          irclog "* #{@nick}[#{where}] #{message}", where
557     end
558   end
559
560   # quick way to say "okay" (or equivalent) to +where+
561   def okay(where)
562     say where, @lang.get("okay")
563   end
564
565   # log IRC-related message +message+ to a file determined by +where+.
566   # +where+ can be a channel name, or a nick for private message logging
567   def irclog(message, where="server")
568     message = message.chomp
569     stamp = Time.now.strftime("%Y/%m/%d %H:%M:%S")
570     where = where.gsub(/[:!?$*()\/\\<>|"']/, "_")
571     unless(@logs.has_key?(where))
572       @logs[where] = File.new("#{@botclass}/logs/#{where}", "a")
573       @logs[where].sync = true
574     end
575     @logs[where].puts "[#{stamp}] #{message}"
576     #debug "[#{stamp}] <#{where}> #{message}"
577   end
578
579   # set topic of channel +where+ to +topic+
580   def topic(where, topic)
581     sendq "TOPIC #{where} :#{topic}"
582   end
583
584   # disconnect from the server and cleanup all plugins and modules
585   def shutdown(message = nil)
586     debug "Shutting down ..."
587     ## No we don't restore them ... let everything run through
588     # begin
589     #   trap("SIGINT", "DEFAULT")
590     #   trap("SIGTERM", "DEFAULT")
591     #   trap("SIGHUP", "DEFAULT")
592     # rescue => e
593     #   debug "failed to restore signals: #{e.inspect}\nProbably running on windows?"
594     # end
595     message = @lang.get("quit") if (message.nil? || message.empty?)
596     if @socket.connected?
597       debug "Clearing socket"
598       @socket.clearq
599       debug "Sending quit message"
600       @socket.puts "QUIT :#{message}"
601       debug "Flushing socket"
602       @socket.flush
603       debug "Shutting down socket"
604       @socket.shutdown
605     end
606     debug "Logging quits"
607     @channels.each_value {|v|
608       irclog "@ quit (#{message})", v.name
609     }
610     debug "Saving"
611     save
612     debug "Cleaning up"
613     @plugins.cleanup
614     # debug "Closing registries"
615     # @registry.close
616     debug "Cleaning up the db environment"
617     DBTree.cleanup_env
618     log "rbot quit (#{message})"
619   end
620
621   # message:: optional IRC quit message
622   # quit IRC, shutdown the bot
623   def quit(message=nil)
624     begin
625       shutdown(message)
626     ensure
627       log_session_end
628       exit 0
629     end
630   end
631
632   # totally shutdown and respawn the bot
633   def restart(message = false)
634     msg = message ? message : "restarting, back in #{@config['server.reconnect_wait']}..."
635     shutdown(msg)
636     sleep @config['server.reconnect_wait']
637     # now we re-exec
638     # Note, this fails on Windows
639     exec($0, *@argv)
640   end
641
642   # call the save method for bot's config, keywords, auth and all plugins
643   def save
644     @config.save
645     @keywords.save
646     @auth.save
647     @plugins.save
648     DBTree.cleanup_logs
649   end
650
651   # call the rescan method for the bot's lang, keywords and all plugins
652   def rescan
653     @lang.rescan
654     @plugins.rescan
655     @keywords.rescan
656   end
657
658   # channel:: channel to join
659   # key::     optional channel key if channel is +s
660   # join a channel
661   def join(channel, key=nil)
662     if(key)
663       sendq "JOIN #{channel} :#{key}"
664     else
665       sendq "JOIN #{channel}"
666     end
667   end
668
669   # part a channel
670   def part(channel, message="")
671     sendq "PART #{channel} :#{message}"
672   end
673
674   # attempt to change bot's nick to +name+
675   def nickchg(name)
676       sendq "NICK #{name}"
677   end
678
679   # changing mode
680   def mode(channel, mode, target)
681       sendq "MODE #{channel} #{mode} #{target}"
682   end
683
684   # m::     message asking for help
685   # topic:: optional topic help is requested for
686   # respond to online help requests
687   def help(topic=nil)
688     topic = nil if topic == ""
689     case topic
690     when nil
691       helpstr = "help topics: core, auth, keywords"
692       helpstr += @plugins.helptopics
693       helpstr += " (help <topic> for more info)"
694     when /^core$/i
695       helpstr = corehelp
696     when /^core\s+(.+)$/i
697       helpstr = corehelp $1
698     when /^auth$/i
699       helpstr = @auth.help
700     when /^auth\s+(.+)$/i
701       helpstr = @auth.help $1
702     when /^keywords$/i
703       helpstr = @keywords.help
704     when /^keywords\s+(.+)$/i
705       helpstr = @keywords.help $1
706     else
707       unless(helpstr = @plugins.help(topic))
708         helpstr = "no help for topic #{topic}"
709       end
710     end
711     return helpstr
712   end
713
714   # returns a string describing the current status of the bot (uptime etc)
715   def status
716     secs_up = Time.new - @startup_time
717     uptime = Utils.secs_to_string secs_up
718     # return "Uptime #{uptime}, #{@plugins.length} plugins active, #{@registry.length} items stored in registry, #{@socket.lines_sent} lines sent, #{@socket.lines_received} received."
719     return "Uptime #{uptime}, #{@plugins.length} plugins active, #{@socket.lines_sent} lines sent, #{@socket.lines_received} received."
720   end
721
722   # we'll ping the server every 30 seconds or so, and expect a response
723   # before the next one come around..
724   def start_server_pings
725     stop_server_pings
726     return unless @config['server.ping_timeout'] > 0
727     # we want to respond to a hung server within 30 secs or so
728     @ping_timer = @timer.add(30) {
729       @last_ping = Time.now
730       @socket.puts "PING :rbot"
731     }
732     @pong_timer = @timer.add(10) {
733       unless @last_ping.nil?
734         diff = Time.now - @last_ping
735         unless diff < @config['server.ping_timeout']
736           debug "no PONG from server for #{diff} seconds, reconnecting"
737           begin
738             @socket.shutdown
739           rescue
740             debug "couldn't shutdown connection (already shutdown?)"
741           end
742           @last_ping = nil
743           raise TimeoutError, "no PONG from server in #{diff} seconds"
744         end
745       end
746     }
747   end
748
749   def stop_server_pings
750     @last_ping = nil
751     # stop existing timers if running
752     unless @ping_timer.nil?
753       @timer.remove @ping_timer
754       @ping_timer = nil
755     end
756     unless @pong_timer.nil?
757       @timer.remove @pong_timer
758       @pong_timer = nil
759     end
760   end
761
762   private
763
764   # handle help requests for "core" topics
765   def corehelp(topic="")
766     case topic
767       when "quit"
768         return "quit [<message>] => quit IRC with message <message>"
769       when "restart"
770         return "restart => completely stop and restart the bot (including reconnect)"
771       when "join"
772         return "join <channel> [<key>] => join channel <channel> with secret key <key> if specified. #{@nick} also responds to invites if you have the required access level"
773       when "part"
774         return "part <channel> => part channel <channel>"
775       when "hide"
776         return "hide => part all channels"
777       when "save"
778         return "save => save current dynamic data and configuration"
779       when "rescan"
780         return "rescan => reload modules and static facts"
781       when "nick"
782         return "nick <nick> => attempt to change nick to <nick>"
783       when "say"
784         return "say <channel>|<nick> <message> => say <message> to <channel> or in private message to <nick>"
785       when "action"
786         return "action <channel>|<nick> <message> => does a /me <message> to <channel> or in private message to <nick>"
787         #       when "topic"
788         #         return "topic <channel> <message> => set topic of <channel> to <message>"
789       when "quiet"
790         return "quiet [in here|<channel>] => with no arguments, stop speaking in all channels, if \"in here\", stop speaking in this channel, or stop speaking in <channel>"
791       when "talk"
792         return "talk [in here|<channel>] => with no arguments, resume speaking in all channels, if \"in here\", resume speaking in this channel, or resume speaking in <channel>"
793       when "version"
794         return "version => describes software version"
795       when "botsnack"
796         return "botsnack => reward #{@nick} for being good"
797       when "hello"
798         return "hello|hi|hey|yo [#{@nick}] => greet the bot"
799       else
800         return "Core help topics: quit, restart, config, join, part, hide, save, rescan, nick, say, action, topic, quiet, talk, version, botsnack, hello"
801     end
802   end
803
804   # handle incoming IRC PRIVMSG +m+
805   def onprivmsg(m)
806     # log it first
807     if(m.action?)
808       if(m.private?)
809         irclog "* [#{m.sourcenick}(#{m.sourceaddress})] #{m.message}", m.sourcenick
810       else
811         irclog "* #{m.sourcenick} #{m.message}", m.target
812       end
813     else
814       if(m.public?)
815         irclog "<#{m.sourcenick}> #{m.message}", m.target
816       else
817         irclog "[#{m.sourcenick}(#{m.sourceaddress})] #{m.message}", m.sourcenick
818       end
819     end
820
821     @config['irc.ignore_users'].each { |mask| return if Irc.netmaskmatch(mask,m.source) }
822
823     # pass it off to plugins that want to hear everything
824     @plugins.delegate "listen", m
825
826     if(m.private? && m.message =~ /^\001PING\s+(.+)\001/)
827       notice m.sourcenick, "\001PING #$1\001"
828       irclog "@ #{m.sourcenick} pinged me"
829       return
830     end
831
832     if(m.address?)
833       delegate_privmsg(m)
834       case m.message
835         when (/^join\s+(\S+)\s+(\S+)$/i)
836           join $1, $2 if(@auth.allow?("join", m.source, m.replyto))
837         when (/^join\s+(\S+)$/i)
838           join $1 if(@auth.allow?("join", m.source, m.replyto))
839         when (/^part$/i)
840           part m.target if(m.public? && @auth.allow?("join", m.source, m.replyto))
841         when (/^part\s+(\S+)$/i)
842           part $1 if(@auth.allow?("join", m.source, m.replyto))
843         when (/^quit(?:\s+(.*))?$/i)
844           quit $1 if(@auth.allow?("quit", m.source, m.replyto))
845         when (/^restart(?:\s+(.*))?$/i)
846           restart $1 if(@auth.allow?("quit", m.source, m.replyto))
847         when (/^hide$/i)
848           join 0 if(@auth.allow?("join", m.source, m.replyto))
849         when (/^save$/i)
850           if(@auth.allow?("config", m.source, m.replyto))
851             save
852             m.okay
853           end
854         when (/^nick\s+(\S+)$/i)
855           nickchg($1) if(@auth.allow?("nick", m.source, m.replyto))
856         when (/^say\s+(\S+)\s+(.*)$/i)
857           say $1, $2 if(@auth.allow?("say", m.source, m.replyto))
858         when (/^action\s+(\S+)\s+(.*)$/i)
859           action $1, $2 if(@auth.allow?("say", m.source, m.replyto))
860           # when (/^topic\s+(\S+)\s+(.*)$/i)
861           #   topic $1, $2 if(@auth.allow?("topic", m.source, m.replyto))
862         when (/^mode\s+(\S+)\s+(\S+)\s+(.*)$/i)
863           mode $1, $2, $3 if(@auth.allow?("mode", m.source, m.replyto))
864         when (/^ping$/i)
865           say m.replyto, "pong"
866         when (/^rescan$/i)
867           if(@auth.allow?("config", m.source, m.replyto))
868             m.reply "Saving ..."
869             save
870             m.reply "Rescanning ..."
871             rescan
872             m.okay
873           end
874         when (/^quiet$/i)
875           if(auth.allow?("talk", m.source, m.replyto))
876             m.okay
877             @channels.each_value {|c| c.quiet = true }
878           end
879         when (/^quiet in (\S+)$/i)
880           where = $1
881           if(auth.allow?("talk", m.source, m.replyto))
882             m.okay
883             where.gsub!(/^here$/, m.target) if m.public?
884             @channels[where].quiet = true if(@channels.has_key?(where))
885           end
886         when (/^talk$/i)
887           if(auth.allow?("talk", m.source, m.replyto))
888             @channels.each_value {|c| c.quiet = false }
889             m.okay
890           end
891         when (/^talk in (\S+)$/i)
892           where = $1
893           if(auth.allow?("talk", m.source, m.replyto))
894             where.gsub!(/^here$/, m.target) if m.public?
895             @channels[where].quiet = false if(@channels.has_key?(where))
896             m.okay
897           end
898         when (/^status\??$/i)
899           m.reply status if auth.allow?("status", m.source, m.replyto)
900         when (/^registry stats$/i)
901           if auth.allow?("config", m.source, m.replyto)
902             m.reply @registry.stat.inspect
903           end
904         when (/^(help\s+)?config(\s+|$)/)
905           @config.privmsg(m)
906         when (/^(version)|(introduce yourself)$/i)
907           say m.replyto, "I'm a v. #{$version} rubybot, (c) Tom Gilbert - http://linuxbrit.co.uk/rbot/"
908         when (/^help(?:\s+(.*))?$/i)
909           say m.replyto, help($1)
910           #TODO move these to a "chatback" plugin
911         when (/^(botsnack|ciggie)$/i)
912           say m.replyto, @lang.get("thanks_X") % m.sourcenick if(m.public?)
913           say m.replyto, @lang.get("thanks") if(m.private?)
914         when (/^(hello|howdy|hola|salut|bonjour|sup|niihau|hey|hi(\W|$)|yo(\W|$)).*/i)
915           say m.replyto, @lang.get("hello_X") % m.sourcenick if(m.public?)
916           say m.replyto, @lang.get("hello") if(m.private?)
917       end
918     else
919       # stuff to handle when not addressed
920       case m.message
921         when (/^\s*(hello|howdy|hola|salut|bonjour|sup|niihau|hey|hi|yo(\W|$))[\s,-.]+#{Regexp.escape(@nick)}$/i)
922           say m.replyto, @lang.get("hello_X") % m.sourcenick
923         when (/^#{Regexp.escape(@nick)}!*$/)
924           say m.replyto, @lang.get("hello_X") % m.sourcenick
925         else
926           @keywords.privmsg(m)
927       end
928     end
929   end
930
931   # log a message. Internal use only.
932   def log_sent(type, where, message)
933     case type
934       when "NOTICE"
935         if(where =~ /^#/)
936           irclog "-=#{@nick}=- #{message}", where
937         elsif (where =~ /(\S*)!.*/)
938              irclog "[-=#{where}=-] #{message}", $1
939         else
940              irclog "[-=#{where}=-] #{message}"
941         end
942       when "PRIVMSG"
943         if(where =~ /^#/)
944           irclog "<#{@nick}> #{message}", where
945         elsif (where =~ /^(\S*)!.*$/)
946           irclog "[msg(#{where})] #{message}", $1
947         else
948           irclog "[msg(#{where})] #{message}", where
949         end
950     end
951   end
952
953   def onjoin(m)
954     @channels[m.channel] = IRCChannel.new(m.channel) unless(@channels.has_key?(m.channel))
955     if(m.address?)
956       debug "joined channel #{m.channel}"
957       irclog "@ Joined channel #{m.channel}", m.channel
958     else
959       irclog "@ #{m.sourcenick} joined channel #{m.channel}", m.channel
960       @channels[m.channel].users[m.sourcenick] = Hash.new
961       @channels[m.channel].users[m.sourcenick]["mode"] = ""
962     end
963
964     @plugins.delegate("listen", m)
965     @plugins.delegate("join", m)
966   end
967
968   def onpart(m)
969     if(m.address?)
970       debug "left channel #{m.channel}"
971       irclog "@ Left channel #{m.channel} (#{m.message})", m.channel
972       @channels.delete(m.channel)
973     else
974       irclog "@ #{m.sourcenick} left channel #{m.channel} (#{m.message})", m.channel
975       if @channels.has_key?(m.channel)
976         @channels[m.channel].users.delete(m.sourcenick)
977       else
978         warning "got part for channel '#{channel}' I didn't think I was in\n"
979         # exit 2
980       end
981     end
982
983     # delegate to plugins
984     @plugins.delegate("listen", m)
985     @plugins.delegate("part", m)
986   end
987
988   # respond to being kicked from a channel
989   def onkick(m)
990     if(m.address?)
991       debug "kicked from channel #{m.channel}"
992       @channels.delete(m.channel)
993       irclog "@ You have been kicked from #{m.channel} by #{m.sourcenick} (#{m.message})", m.channel
994     else
995       @channels[m.channel].users.delete(m.sourcenick)
996       irclog "@ #{m.target} has been kicked from #{m.channel} by #{m.sourcenick} (#{m.message})", m.channel
997     end
998
999     @plugins.delegate("listen", m)
1000     @plugins.delegate("kick", m)
1001   end
1002
1003   def ontopic(m)
1004     @channels[m.channel] = IRCChannel.new(m.channel) unless(@channels.has_key?(m.channel))
1005     @channels[m.channel].topic = m.topic if !m.topic.nil?
1006     @channels[m.channel].topic.timestamp = m.timestamp if !m.timestamp.nil?
1007     @channels[m.channel].topic.by = m.source if !m.source.nil?
1008
1009     debug "topic of channel #{m.channel} is now #{@channels[m.channel].topic}"
1010   end
1011
1012   # delegate a privmsg to auth, keyword or plugin handlers
1013   def delegate_privmsg(message)
1014     [@auth, @plugins, @keywords].each {|m|
1015       break if m.privmsg(message)
1016     }
1017   end
1018 end
1019
1020 end