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