]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - lib/rbot/plugins.rb
Fix some save/quit/rescan races
[user/henk/code/ruby/rbot.git] / lib / rbot / plugins.rb
1 require 'singleton'
2
3 module Irc
4     BotConfig.register BotConfigArrayValue.new('plugins.blacklist',
5       :default => [], :wizard => false, :requires_rescan => true,
6       :desc => "Plugins that should not be loaded")
7 module Plugins
8   require 'rbot/messagemapper'
9
10 =begin
11   base class for all rbot plugins
12   certain methods will be called if they are provided, if you define one of
13   the following methods, it will be called as appropriate:
14
15   map(template, options)::
16   map!(template, options)::
17      map is the new, cleaner way to respond to specific message formats
18      without littering your plugin code with regexps. The difference
19      between map and map! is that map! will not register the new command
20      as an alternative name for the plugin.
21
22      Examples:
23
24        plugin.map 'karmastats', :action => 'karma_stats'
25
26        # while in the plugin...
27        def karma_stats(m, params)
28          m.reply "..."
29        end
30
31        # the default action is the first component
32        plugin.map 'karma'
33
34        # attributes can be pulled out of the match string
35        plugin.map 'karma for :key'
36        plugin.map 'karma :key'
37
38        # while in the plugin...
39        def karma(m, params)
40          item = params[:key]
41          m.reply 'karma for #{item}'
42        end
43
44        # you can setup defaults, to make parameters optional
45        plugin.map 'karma :key', :defaults => {:key => 'defaultvalue'}
46
47        # the default auth check is also against the first component
48        # but that can be changed
49        plugin.map 'karmastats', :auth => 'karma'
50
51        # maps can be restricted to public or private message:
52        plugin.map 'karmastats', :private false,
53        plugin.map 'karmastats', :public false,
54      end
55
56   listen(UserMessage)::
57                          Called for all messages of any type. To
58                          differentiate them, use message.kind_of? It'll be
59                          either a PrivMessage, NoticeMessage, KickMessage,
60                          QuitMessage, PartMessage, JoinMessage, NickMessage,
61                          etc.
62
63   privmsg(PrivMessage)::
64                          called for a PRIVMSG if the first word matches one
65                          the plugin register()d for. Use m.plugin to get
66                          that word and m.params for the rest of the message,
67                          if applicable.
68
69   kick(KickMessage)::
70                          Called when a user (or the bot) is kicked from a
71                          channel the bot is in.
72
73   join(JoinMessage)::
74                          Called when a user (or the bot) joins a channel
75
76   part(PartMessage)::
77                          Called when a user (or the bot) parts a channel
78
79   quit(QuitMessage)::
80                          Called when a user (or the bot) quits IRC
81
82   nick(NickMessage)::
83                          Called when a user (or the bot) changes Nick
84   topic(TopicMessage)::
85                          Called when a user (or the bot) changes a channel
86                          topic
87
88   connect()::            Called when a server is joined successfully, but
89                          before autojoin channels are joined (no params)
90
91   save::                 Called when you are required to save your plugin's
92                          state, if you maintain data between sessions
93
94   cleanup::              called before your plugin is "unloaded", prior to a
95                          plugin reload or bot quit - close any open
96                          files/connections or flush caches here
97 =end
98
99   class BotModule
100     attr_reader :bot   # the associated bot
101     attr_reader :botmodule_class # the botmodule class (:coremodule or :plugin)
102
103     # initialise your bot module. Always call super if you override this method,
104     # as important variables are set up for you
105     def initialize(kl)
106       @manager = Plugins::pluginmanager
107       @bot = @manager.bot
108
109       @botmodule_class = kl.to_sym
110       @botmodule_triggers = Array.new
111
112       @handler = MessageMapper.new(self)
113       @registry = BotRegistryAccessor.new(@bot, self.class.to_s.gsub(/^.*::/, ""))
114
115       @manager.add_botmodule(self)
116     end
117
118     def flush_registry
119       # debug "Flushing #{@registry}"
120       @registry.flush
121     end
122
123     def cleanup
124       # debug "Closing #{@registry}"
125       @registry.close
126     end
127
128     def handle(m)
129       @handler.handle(m)
130     end
131
132     def map(*args)
133       @handler.map(self, *args)
134       # register this map
135       name = @handler.last.items[0]
136       self.register name, :auth => nil
137       unless self.respond_to?('privmsg')
138         def self.privmsg(m)
139           handle(m)
140         end
141       end
142     end
143
144     def map!(*args)
145       @handler.map(self, *args)
146       # register this map
147       name = @handler.last.items[0]
148       self.register name, :auth => nil, :hidden => true
149       unless self.respond_to?('privmsg')
150         def self.privmsg(m)
151           handle(m)
152         end
153       end
154     end
155
156     # Sets the default auth for command path _cmd_ to _val_ on channel _chan_:
157     # usually _chan_ is either "*" for everywhere, public and private (in which
158     # case it can be omitted) or "?" for private communications
159     #
160     def default_auth(cmd, val, chan="*")
161       case cmd
162       when "*", ""
163         c = nil
164       else
165         c = cmd
166       end
167       Auth::defaultbotuser.set_default_permission(propose_default_path(c), val)
168     end
169
170     # Gets the default command path which would be given to command _cmd_
171     def propose_default_path(cmd)
172       [name, cmd].compact.join("::")
173     end
174
175     # return an identifier for this plugin, defaults to a list of the message
176     # prefixes handled (used for error messages etc)
177     def name
178       self.class.to_s.downcase.sub(/^#<module:.*?>::/,"").sub(/(plugin|module)?$/,"")
179     end
180
181     # just calls name
182     def to_s
183       name
184     end
185
186     # return a help string for your module. for complex modules, you may wish
187     # to break your help into topics, and return a list of available topics if
188     # +topic+ is nil. +plugin+ is passed containing the matching prefix for
189     # this message - if your plugin handles multiple prefixes, make sure you
190     # return the correct help for the prefix requested
191     def help(plugin, topic)
192       "no help"
193     end
194
195     # register the plugin as a handler for messages prefixed +name+
196     # this can be called multiple times for a plugin to handle multiple
197     # message prefixes
198     def register(cmd, opts={})
199       raise ArgumentError, "Second argument must be a hash!" unless opts.kind_of?(Hash)
200       return if @manager.knows?(cmd, @botmodule_class)
201       if opts.has_key?(:auth)
202         @manager.register(self, cmd, opts[:auth])
203       else
204         @manager.register(self, cmd, propose_default_path(cmd))
205       end
206       @botmodule_triggers << cmd unless opts.fetch(:hidden, false)
207     end
208
209     # default usage method provided as a utility for simple plugins. The
210     # MessageMapper uses 'usage' as its default fallback method.
211     def usage(m, params = {})
212       m.reply "incorrect usage, ask for help using '#{@bot.nick}: help #{m.plugin}'"
213     end
214
215   end
216
217   class CoreBotModule < BotModule
218     def initialize
219       super(:coremodule)
220     end
221   end
222
223   class Plugin < BotModule
224     def initialize
225       super(:plugin)
226     end
227   end
228
229   # Singleton to manage multiple plugins and delegate messages to them for
230   # handling
231   class PluginManagerClass
232     include Singleton
233     attr_reader :bot
234     attr_reader :botmodules
235
236     def initialize
237       bot_associate(nil)
238
239       @dirs = []
240     end
241
242     # Reset lists of botmodules
243     def reset_botmodule_lists
244       @botmodules = {
245         :coremodule => [],
246         :plugin => []
247       }
248
249       @commandmappers = {
250         :coremodule => {},
251         :plugin => {}
252       }
253
254     end
255
256     # Associate with bot _bot_
257     def bot_associate(bot)
258       reset_botmodule_lists
259       @bot = bot
260     end
261
262     # Returns +true+ if _name_ is a known botmodule of class kl
263     def knows?(name, kl)
264       return @commandmappers[kl.to_sym].has_key?(name.to_sym)
265     end
266
267     # Registers botmodule _botmodule_ with command _cmd_ and command path _auth_path_
268     def register(botmodule, cmd, auth_path)
269       raise TypeError, "First argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
270       kl = botmodule.botmodule_class
271       @commandmappers[kl.to_sym][cmd.to_sym] = {:botmodule => botmodule, :auth => auth_path}
272       h = @commandmappers[kl.to_sym][cmd.to_sym]
273       # debug "Registered command mapper for #{cmd.to_sym} (#{kl.to_sym}): #{h[:botmodule].name} with command path #{h[:auth]}"
274     end
275
276     def add_botmodule(botmodule)
277       raise TypeError, "Argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
278       kl = botmodule.botmodule_class
279       raise "#{kl.to_s} #{botmodule.name} already registered!" if @botmodules[kl.to_sym].include?(botmodule)
280       @botmodules[kl.to_sym] << botmodule
281     end
282
283     # Returns an array of the loaded plugins
284     def core_modules
285       @botmodules[:coremodule]
286     end
287
288     # Returns an array of the loaded plugins
289     def plugins
290       @botmodules[:plugin]
291     end
292
293     # Returns a hash of the registered message prefixes and associated
294     # plugins
295     def plugin_commands
296       @commandmappers[:plugin]
297     end
298
299     # Returns a hash of the registered message prefixes and associated
300     # core modules
301     def core_commands
302       @commandmappers[:coremodule]
303     end
304
305     # Makes a string of error _err_ by adding text _str_
306     def report_error(str, err)
307       ([str, err.inspect] + err.backtrace).join("\n")
308     end
309
310     # This method is the one that actually loads a module from the
311     # file _fname_
312     #
313     # _desc_ is a simple description of what we are loading (plugin/botmodule/whatever)
314     #
315     # It returns the Symbol :loaded on success, and an Exception
316     # on failure
317     #
318     def load_botmodule_file(fname, desc=nil)
319       # create a new, anonymous module to "house" the plugin
320       # the idea here is to prevent namespace pollution. perhaps there
321       # is another way?
322       plugin_module = Module.new
323
324       desc = desc.to_s + " " if desc
325
326       begin
327         plugin_string = IO.readlines(fname).join("")
328         debug "loading #{desc}#{fname}"
329         plugin_module.module_eval(plugin_string, fname)
330         return :loaded
331       rescue Exception => err
332         # rescue TimeoutError, StandardError, NameError, LoadError, SyntaxError => err
333         warning report_error("#{desc}#{fname} load failed", err)
334         bt = err.backtrace.select { |line|
335           line.match(/^(\(eval\)|#{fname}):\d+/)
336         }
337         bt.map! { |el|
338           el.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
339             "#{fname}#{$1}#{$3}"
340           }
341         }
342         msg = err.to_str.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
343           "#{fname}#{$1}#{$3}"
344         }
345         newerr = err.class.new(msg)
346         newerr.set_backtrace(bt)
347         return newerr
348       end
349     end
350     private :load_botmodule_file
351
352     # add one or more directories to the list of directories to
353     # load botmodules from
354     #
355     # TODO find a way to specify necessary plugins which _must_ be loaded
356     #
357     def add_botmodule_dir(*dirlist)
358       @dirs += dirlist
359       debug "Botmodule loading path: #{@dirs.join(', ')}"
360     end
361
362     # load plugins from pre-assigned list of directories
363     def scan
364       @failed = Array.new
365       @ignored = Array.new
366       processed = Hash.new
367
368       @bot.config['plugins.blacklist'].each { |p|
369         pn = p + ".rb"
370         processed[pn.intern] = :blacklisted
371       }
372
373       dirs = @dirs
374       dirs.each {|dir|
375         if(FileTest.directory?(dir))
376           d = Dir.new(dir)
377           d.sort.each {|file|
378
379             next if(file =~ /^\./)
380
381             if processed.has_key?(file.intern)
382               @ignored << {:name => file, :dir => dir, :reason => processed[file.intern]}
383               next
384             end
385
386             if(file =~ /^(.+\.rb)\.disabled$/)
387               # GB: Do we want to do this? This means that a disabled plugin in a directory
388               #     will disable in all subsequent directories. This was probably meant
389               #     to be used before plugins.blacklist was implemented, so I think
390               #     we don't need this anymore
391               processed[$1.intern] = :disabled
392               @ignored << {:name => $1, :dir => dir, :reason => processed[$1.intern]}
393               next
394             end
395
396             next unless(file =~ /\.rb$/)
397
398             did_it = load_botmodule_file("#{dir}/#{file}", "plugin")
399             case did_it
400             when Symbol
401               processed[file.intern] = did_it
402             when Exception
403               @failed <<  { :name => file, :dir => dir, :reason => did_it }
404             end
405
406           }
407         end
408       }
409       debug "finished loading plugins: #{status(true)}"
410     end
411
412     # call the save method for each active plugin
413     def save
414       delegate 'flush_registry'
415       delegate 'save'
416     end
417
418     # call the cleanup method for each active plugin
419     def cleanup
420       delegate 'cleanup'
421       reset_botmodule_lists
422     end
423
424     # drop all plugins and rescan plugins on disk
425     # calls save and cleanup for each plugin before dropping them
426     def rescan
427       save
428       cleanup
429       scan
430     end
431
432     def status(short=false)
433       list = ""
434       if self.core_length > 0
435         list << "#{self.core_length} core module#{'s' if core_length > 1}"
436         if short
437           list << " loaded"
438         else
439           list << ": " + core_modules.collect{ |p| p.name}.sort.join(", ")
440         end
441       else
442         list << "no core botmodules loaded"
443       end
444       # Active plugins first
445       if(self.length > 0)
446         list << "; #{self.length} plugin#{'s' if length > 1}"
447         if short
448           list << " loaded"
449         else
450           list << ": " + plugins.collect{ |p| p.name}.sort.join(", ")
451         end
452       else
453         list << "no plugins active"
454       end
455       # Ignored plugins next
456       unless @ignored.empty?
457         list << "; #{Underline}#{@ignored.length} plugin#{'s' if @ignored.length > 1} ignored#{Underline}"
458         list << ": use #{Bold}help ignored plugins#{Bold} to see why" unless short
459       end
460       # Failed plugins next
461       unless @failed.empty?
462         list << "; #{Reverse}#{@failed.length} plugin#{'s' if @failed.length > 1} failed to load#{Reverse}"
463         list << ": use #{Bold}help failed plugins#{Bold} to see why" unless short
464       end
465       list
466     end
467
468     # return list of help topics (plugin names)
469     def helptopics
470       return status
471     end
472
473     def length
474       plugins.length
475     end
476
477     def core_length
478       core_modules.length
479     end
480
481     # return help for +topic+ (call associated plugin's help method)
482     def help(topic="")
483       case topic
484       when /fail(?:ed)?\s*plugins?.*(trace(?:back)?s?)?/
485         # debug "Failures: #{@failed.inspect}"
486         return "no plugins failed to load" if @failed.empty?
487         return @failed.inject(Array.new) { |list, p|
488           list << "#{Bold}#{p[:name]}#{Bold} in #{p[:dir]} failed"
489           list << "with error #{p[:reason].class}: #{p[:reason]}"
490           list << "at #{p[:reason].backtrace.join(', ')}" if $1 and not p[:reason].backtrace.empty?
491           list
492         }.join("\n")
493       when /ignored?\s*plugins?/
494         return "no plugins were ignored" if @ignored.empty?
495         return @ignored.inject(Array.new) { |list, p|
496           case p[:reason]
497           when :loaded
498             list << "#{p[:name]} in #{p[:dir]} (overruled by previous)"
499           else
500             list << "#{p[:name]} in #{p[:dir]} (#{p[:reason].to_s})"
501           end
502           list
503         }.join(", ")
504       when /^(\S+)\s*(.*)$/
505         key = $1
506         params = $2
507         (core_modules + plugins).each { |p|
508           next unless p.name == key
509           begin
510             return p.help(key, params)
511           rescue Exception => err
512             #rescue TimeoutError, StandardError, NameError, SyntaxError => err
513             error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
514           end
515         }
516         k = key.to_sym
517         [core_commands, plugin_commands].each { |pl|
518           next unless pl.has_key?(k)
519           p = pl[k][:botmodule] 
520           begin
521             return p.help(p.name, topic)
522           rescue Exception => err
523             #rescue TimeoutError, StandardError, NameError, SyntaxError => err
524             error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
525           end
526         }
527       end
528       return false
529     end
530
531     # see if each plugin handles +method+, and if so, call it, passing
532     # +message+ as a parameter
533     def delegate(method, *args)
534       # debug "Delegating #{method.inspect}"
535       [core_modules, plugins].each { |pl|
536         pl.each {|p|
537           if(p.respond_to? method)
538             begin
539               # debug "#{p.botmodule_class} #{p.name} responds"
540               p.send method, *args
541             rescue Exception => err
542               raise if err.kind_of?(SystemExit)
543               error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
544               raise if err.kind_of?(BDB::Fatal)
545             end
546           end
547         }
548       }
549       # debug "Finished delegating #{method.inspect}"
550     end
551
552     # see if we have a plugin that wants to handle this message, if so, pass
553     # it to the plugin and return true, otherwise false
554     def privmsg(m)
555       # debug "Delegating privmsg #{m.message.inspect} from #{m.source} to #{m.replyto} with pluginkey #{m.plugin.inspect}"
556       return unless m.plugin
557       [core_commands, plugin_commands].each { |pl|
558         # We do it this way to skip creating spurious keys
559         # FIXME use fetch?
560         k = m.plugin.to_sym
561         if pl.has_key?(k)
562           p = pl[k][:botmodule]
563           a = pl[k][:auth]
564         else
565           p = nil
566           a = nil
567         end
568         if p
569           # We check here for things that don't check themselves
570           # (e.g. mapped things)
571           # debug "Checking auth ..."
572           if a.nil? || @bot.auth.allow?(a, m.source, m.replyto)
573             # debug "Checking response ..."
574             if p.respond_to?("privmsg")
575               begin
576                 # debug "#{p.botmodule_class} #{p.name} responds"
577                 p.privmsg(m)
578               rescue Exception => err
579                 raise if err.kind_of?(SystemExit)
580                 error report_error("#{p.botmodule_class} #{p.name} privmsg() failed:", err)
581                 raise if err.kind_of?(BDB::Fatal)
582               end
583               # debug "Successfully delegated #{m.message}"
584               return true
585             else
586               # debug "#{p.botmodule_class} #{p.name} is registered, but it doesn't respond to privmsg()"
587             end
588           else
589             # debug "#{p.botmodule_class} #{p.name} is registered, but #{m.source} isn't allowed to call #{m.plugin.inspect} on #{m.replyto}"
590           end
591         else
592           # debug "No #{pl.values.first[:botmodule].botmodule_class} registered #{m.plugin.inspect}" unless pl.empty?
593         end
594         # debug "Finished delegating privmsg with key #{m.plugin.inspect}" + ( pl.empty? ? "" : " to #{pl.values.first[:botmodule].botmodule_class}s" )
595       }
596       return false
597       # debug "Finished delegating privmsg with key #{m.plugin.inspect}"
598     end
599   end
600
601   # Returns the only PluginManagerClass instance
602   def Plugins.pluginmanager
603     return PluginManagerClass.instance
604   end
605
606 end
607 end