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