]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - lib/rbot/plugins.rb
plugins: allow rescanning of one botmodule only
[user/henk/code/ruby/rbot.git] / lib / rbot / plugins.rb
1 #-- vim:sw=2:et
2 #++
3 #
4 # :title: rbot plugin management
5
6 require 'singleton'
7
8 module Irc
9 class Bot
10     Config.register Config::ArrayValue.new('plugins.blacklist',
11       :default => [], :wizard => false, :requires_rescan => true,
12       :desc => "Plugins that should not be loaded")
13     Config.register Config::ArrayValue.new('plugins.whitelist',
14       :default => [], :wizard => false, :requires_rescan => true,
15       :desc => "Only whitelisted plugins will be loaded unless the list is empty")
16 module Plugins
17   require 'rbot/messagemapper'
18
19 =begin rdoc
20   BotModule is the base class for the modules that enhance the rbot
21   functionality. Rather than subclassing BotModule, however, one should
22   subclass either CoreBotModule (reserved for system modules) or Plugin
23   (for user plugins).
24
25   A BotModule interacts with Irc events by defining one or more of the following
26   methods, which get called as appropriate when the corresponding Irc event
27   happens.
28
29   map(template, options)::
30   map!(template, options)::
31      map is the new, cleaner way to respond to specific message formats without
32      littering your plugin code with regexps, and should be used instead of
33      #register() and #privmsg() (see below) when possible.
34
35      The difference between map and map! is that map! will not register the new
36      command as an alternative name for the plugin.
37
38      Examples:
39
40        plugin.map 'karmastats', :action => 'karma_stats'
41
42        # while in the plugin...
43        def karma_stats(m, params)
44          m.reply "..."
45        end
46
47        # the default action is the first component
48        plugin.map 'karma'
49
50        # attributes can be pulled out of the match string
51        plugin.map 'karma for :key'
52        plugin.map 'karma :key'
53
54        # while in the plugin...
55        def karma(m, params)
56          item = params[:key]
57          m.reply 'karma for #{item}'
58        end
59
60        # you can setup defaults, to make parameters optional
61        plugin.map 'karma :key', :defaults => {:key => 'defaultvalue'}
62
63        # the default auth check is also against the first component
64        # but that can be changed
65        plugin.map 'karmastats', :auth => 'karma'
66
67        # maps can be restricted to public or private message:
68        plugin.map 'karmastats', :private => false
69        plugin.map 'karmastats', :public => false
70
71      See MessageMapper#map for more information on the template format and the
72      allowed options.
73
74   listen(UserMessage)::
75                          Called for all messages of any type. To
76                          differentiate them, use message.kind_of? It'll be
77                          either a PrivMessage, NoticeMessage, KickMessage,
78                          QuitMessage, PartMessage, JoinMessage, NickMessage,
79                          etc.
80
81   ctcp_listen(UserMessage)::
82                          Called for all messages that contain a CTCP command.
83                          Use message.ctcp to get the CTCP command, and
84                          message.message to get the parameter string. To reply,
85                          use message.ctcp_reply, which sends a private NOTICE
86                          to the sender.
87
88   message(PrivMessage)::
89                          Called for all PRIVMSG. Hook on this method if you
90                          need to handle PRIVMSGs regardless of whether they are
91                          addressed to the bot or not, and regardless of
92
93   privmsg(PrivMessage)::
94                          Called for a PRIVMSG if the first word matches one
95                          the plugin #register()ed for. Use m.plugin to get
96                          that word and m.params for the rest of the message,
97                          if applicable.
98
99   unreplied(PrivMessage)::
100                          Called for a PRIVMSG which has not been replied to.
101
102   notice(NoticeMessage)::
103                          Called for all Notices. Please notice that in general
104                          should not be replied to.
105
106   kick(KickMessage)::
107                          Called when a user (or the bot) is kicked from a
108                          channel the bot is in.
109
110   invite(InviteMessage)::
111                          Called when the bot is invited to a channel.
112
113   join(JoinMessage)::
114                          Called when a user (or the bot) joins a channel
115
116   part(PartMessage)::
117                          Called when a user (or the bot) parts a channel
118
119   quit(QuitMessage)::
120                          Called when a user (or the bot) quits IRC
121
122   nick(NickMessage)::
123                          Called when a user (or the bot) changes Nick
124   modechange(ModeChangeMessage)::
125                          Called when a User or Channel mode is changed
126   topic(TopicMessage)::
127                          Called when a user (or the bot) changes a channel
128                          topic
129
130   welcome(WelcomeMessage)::
131                          Called when the welcome message is received on
132                          joining a server succesfully.
133
134   motd(MotdMessage)::
135                          Called when the Message Of The Day is fully
136                          recevied from the server.
137
138   connect::              Called when a server is joined successfully, but
139                          before autojoin channels are joined (no params)
140
141   set_language(String)::
142                          Called when the user sets a new language
143                          whose name is the given String
144
145   save::                 Called when you are required to save your plugin's
146                          state, if you maintain data between sessions
147
148   cleanup::              called before your plugin is "unloaded", prior to a
149                          plugin reload or bot quit - close any open
150                          files/connections or flush caches here
151 =end
152
153   class BotModule
154     # the associated bot
155     attr_reader :bot
156
157     # the plugin registry
158     attr_reader :registry
159
160     # the message map handler
161     attr_reader :handler
162
163     # Initialise your bot module. Always call super if you override this method,
164     # as important variables are set up for you:
165     #
166     # @bot::
167     #   the rbot instance
168     # @registry::
169     #   the botmodule's registry, which can be used to store permanent data
170     #   (see Registry::Accessor for additional documentation)
171     #
172     # Other instance variables which are defined and should not be overwritten
173     # byt the user, but aren't usually accessed directly, are:
174     #
175     # @manager::
176     #   the plugins manager instance
177     # @botmodule_triggers::
178     #   an Array of words this plugin #register()ed itself for
179     # @handler::
180     #   the MessageMapper that handles this plugin's maps
181     #
182     def initialize
183       @manager = Plugins::manager
184       @bot = @manager.bot
185       @priority = nil
186
187       @botmodule_triggers = Array.new
188
189       @handler = MessageMapper.new(self)
190       @registry = @bot.registry_factory.create(@bot.path, self.class.to_s.gsub(/^.*::/, ''))
191
192       @manager.add_botmodule(self)
193       if self.respond_to?('set_language')
194         self.set_language(@bot.lang.language)
195       end
196     end
197
198     # Changing the value of @priority directly will cause problems,
199     # Please use priority=.
200     def priority
201       @priority ||= 1
202     end
203
204     # Returns the symbol :BotModule
205     def botmodule_class
206       :BotModule
207     end
208
209     # Method called to flush the registry, thus ensuring that the botmodule's permanent
210     # data is committed to disk
211     #
212     def flush_registry
213       # debug "Flushing #{@registry}"
214       @registry.flush
215     end
216
217     # Method called to cleanup before the plugin is unloaded. If you overload
218     # this method to handle additional cleanup tasks, remember to call super()
219     # so that the default cleanup actions are taken care of as well.
220     #
221     def cleanup
222       # debug "Closing #{@registry}"
223       @registry.close
224     end
225
226     # Handle an Irc::PrivMessage for which this BotModule has a map. The method
227     # is called automatically and there is usually no need to call it
228     # explicitly.
229     #
230     def handle(m)
231       @handler.handle(m)
232     end
233
234     # Signal to other BotModules that an even happened.
235     #
236     def call_event(ev, *args)
237       @bot.plugins.delegate('event_' + ev.to_s.gsub(/[^\w\?!]+/, '_'), *(args.push Hash.new))
238     end
239
240     # call-seq: map(template, options)
241     #
242     # This is the preferred way to register the BotModule so that it
243     # responds to appropriately-formed messages on Irc.
244     #
245     def map(*args)
246       do_map(false, *args)
247     end
248
249     # call-seq: map!(template, options)
250     #
251     # This is the same as map but doesn't register the new command
252     # as an alternative name for the plugin.
253     #
254     def map!(*args)
255       do_map(true, *args)
256     end
257
258     # Auxiliary method called by #map and #map!
259     def do_map(silent, *args)
260       @handler.map(self, *args)
261       # register this map
262       map = @handler.last
263       name = map.items[0]
264       self.register name, :auth => nil, :hidden => silent
265       @manager.register_map(self, map)
266       unless self.respond_to?('privmsg')
267         def self.privmsg(m) #:nodoc:
268           handle(m)
269         end
270       end
271     end
272
273     # Sets the default auth for command path _cmd_ to _val_ on channel _chan_:
274     # usually _chan_ is either "*" for everywhere, public and private (in which
275     # case it can be omitted) or "?" for private communications
276     #
277     def default_auth(cmd, val, chan="*")
278       case cmd
279       when "*", ""
280         c = nil
281       else
282         c = cmd
283       end
284       Auth::defaultbotuser.set_default_permission(propose_default_path(c), val)
285     end
286
287     # Gets the default command path which would be given to command _cmd_
288     def propose_default_path(cmd)
289       [name, cmd].compact.join("::")
290     end
291
292     # Return an identifier for this plugin, defaults to a list of the message
293     # prefixes handled (used for error messages etc)
294     def name
295       self.class.to_s.downcase.sub(/^#<module:.*?>::/,"").sub(/(plugin|module)?$/,"")
296     end
297
298     # Just calls name
299     def to_s
300       name
301     end
302
303     # Intern the name
304     def to_sym
305       self.name.to_sym
306     end
307
308     # Return a help string for your module. For complex modules, you may wish
309     # to break your help into topics, and return a list of available topics if
310     # +topic+ is nil. +plugin+ is passed containing the matching prefix for
311     # this message - if your plugin handles multiple prefixes, make sure you
312     # return the correct help for the prefix requested
313     def help(plugin, topic)
314       "no help"
315     end
316
317     # Register the plugin as a handler for messages prefixed _cmd_.
318     #
319     # This can be called multiple times for a plugin to handle multiple message
320     # prefixes.
321     #
322     # This command is now superceded by the #map() command, which should be used
323     # instead whenever possible.
324     #
325     def register(cmd, opts={})
326       raise ArgumentError, "Second argument must be a hash!" unless opts.kind_of?(Hash)
327       who = @manager.who_handles?(cmd)
328       if who
329         raise "Command #{cmd} is already handled by #{who.botmodule_class} #{who}" if who != self
330         return
331       end
332       if opts.has_key?(:auth)
333         @manager.register(self, cmd, opts[:auth])
334       else
335         @manager.register(self, cmd, propose_default_path(cmd))
336       end
337       @botmodule_triggers << cmd unless opts.fetch(:hidden, false)
338     end
339
340     # Default usage method provided as a utility for simple plugins. The
341     # MessageMapper uses 'usage' as its default fallback method.
342     #
343     def usage(m, params = {})
344       if params[:failures].respond_to? :find
345         friendly = params[:failures].find do |f|
346           f.kind_of? MessageMapper::FriendlyFailure
347         end
348         if friendly
349           m.reply friendly.friendly
350           return
351         end
352       end
353       m.reply(_("incorrect usage, ask for help using '%{command}'") % {:command => "#{@bot.nick}: help #{m.plugin}"})
354     end
355
356     # Define the priority of the module.  During event delegation, lower
357     # priority modules will be called first.  Default priority is 1
358     def priority=(prio)
359       if @priority != prio
360         @priority = prio
361         @bot.plugins.mark_priorities_dirty
362       end
363     end
364
365     # Directory name to be joined to the botclass to access data files. By
366     # default this is the plugin name itself, but may be overridden, for
367     # example by plugins that share their datafiles or for backwards
368     # compatibilty
369     def dirname
370       name
371     end
372
373     # Filename for a datafile built joining the botclass, plugin dirname and
374     # actual file name
375     def datafile(*fname)
376       @bot.path dirname, *fname
377     end
378   end
379
380   # A CoreBotModule is a BotModule that provides core functionality.
381   #
382   # This class should not be used by user plugins, as it's reserved for system
383   # plugins such as the ones that handle authentication, configuration and basic
384   # functionality.
385   #
386   class CoreBotModule < BotModule
387     def botmodule_class
388       :CoreBotModule
389     end
390   end
391
392   # A Plugin is a BotModule that provides additional functionality.
393   #
394   # A user-defined plugin should subclass this, and then define any of the
395   # methods described in the documentation for BotModule to handle interaction
396   # with Irc events.
397   #
398   class Plugin < BotModule
399     def botmodule_class
400       :Plugin
401     end
402   end
403
404   # Singleton to manage multiple plugins and delegate messages to them for
405   # handling
406   class PluginManagerClass
407     include Singleton
408     attr_reader :bot
409     attr_reader :botmodules
410     attr_reader :maps
411
412     # This is the list of patterns commonly delegated to plugins.
413     # A fast delegation lookup is enabled for them.
414     DEFAULT_DELEGATE_PATTERNS = %r{^(?:
415       connect|names|nick|
416       listen|ctcp_listen|privmsg|unreplied|
417       kick|join|part|quit|
418       save|cleanup|flush_registry|
419       set_.*|event_.*
420     )$}x
421
422     def initialize
423       @botmodules = {
424         :CoreBotModule => [],
425         :Plugin => []
426       }
427
428       @names_hash = Hash.new
429       @commandmappers = Hash.new
430       @maps = Hash.new
431
432       # modules will be sorted on first delegate call
433       @sorted_modules = nil
434
435       @delegate_list = Hash.new { |h, k|
436         h[k] = Array.new
437       }
438
439       @core_module_dirs = []
440       @plugin_dirs = []
441
442       @failed = Array.new
443       @ignored = Array.new
444
445       bot_associate(nil)
446     end
447
448     def inspect
449       ret = self.to_s[0..-2]
450       ret << ' corebotmodules='
451       ret << @botmodules[:CoreBotModule].map { |m|
452         m.name
453       }.inspect
454       ret << ' plugins='
455       ret << @botmodules[:Plugin].map { |m|
456         m.name
457       }.inspect
458       ret << ">"
459     end
460
461     # Reset lists of botmodules
462     #
463     # :botmodule ::
464     #   optional instance of a botmodule to remove from the lists
465     def reset_botmodule_lists(botmodule=nil)
466       if botmodule
467         # deletes only references of the botmodule
468         @botmodules[:CoreBotModule].delete botmodule
469         @botmodules[:Plugin].delete botmodule
470         @names_hash.delete_if {|key, value| value == botmodule}
471         @commandmappers.delete_if {|key, value| value[:botmodule] == botmodule }
472         @delegate_list.each_pair { |cmd, list|
473           list.delete botmodule
474         }
475         @delegate_list.delete_if {|key, value| value.empty?}
476         @maps.delete_if {|key, value| value[:botmodule] == botmodule }
477         @failures_shown = false
478       else
479         @botmodules[:CoreBotModule].clear
480         @botmodules[:Plugin].clear
481         @names_hash.clear
482         @commandmappers.clear
483         @delegate_list.clear
484         @maps.clear
485         @failures_shown = false
486       end
487       mark_priorities_dirty
488     end
489
490     # Associate with bot _bot_
491     def bot_associate(bot)
492       reset_botmodule_lists
493       @bot = bot
494     end
495
496     # Returns the botmodule with the given _name_
497     def [](name)
498       return if not name
499       @names_hash[name.to_sym]
500     end
501
502     # Returns +true+ if a botmodule named _name_ exists.
503     def has_key?(name)
504       return if not name
505       @names_hash.has_key?(name.to_sym)
506     end
507
508     # Returns +true+ if _cmd_ has already been registered as a command
509     def who_handles?(cmd)
510       return nil unless @commandmappers.has_key?(cmd.to_sym)
511       return @commandmappers[cmd.to_sym][:botmodule]
512     end
513
514     # Registers botmodule _botmodule_ with command _cmd_ and command path _auth_path_
515     def register(botmodule, cmd, auth_path)
516       raise TypeError, "First argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
517       @commandmappers[cmd.to_sym] = {:botmodule => botmodule, :auth => auth_path}
518     end
519
520     # Registers botmodule _botmodule_ with map _map_. This adds the map to the #maps hash
521     # which has three keys:
522     #
523     # botmodule:: the associated botmodule
524     # auth:: an array of auth keys checked by the map; the first is the full_auth_path of the map
525     # map:: the actual MessageTemplate object
526     #
527     #
528     def register_map(botmodule, map)
529       raise TypeError, "First argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
530       @maps[map.template] = { :botmodule => botmodule, :auth => [map.options[:full_auth_path]], :map => map }
531     end
532
533     def add_botmodule(botmodule)
534       raise TypeError, "Argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
535       kl = botmodule.botmodule_class
536       if @names_hash.has_key?(botmodule.to_sym)
537         case self[botmodule].botmodule_class
538         when kl
539           raise "#{kl} #{botmodule} already registered!"
540         else
541           raise "#{self[botmodule].botmodule_class} #{botmodule} already registered, cannot re-register as #{kl}"
542         end
543       end
544       @botmodules[kl] << botmodule
545       @names_hash[botmodule.to_sym] = botmodule
546       # add itself to the delegate list for the fast-delegation
547       # of methods like cleanup or privmsg, etc..
548       botmodule.methods.grep(DEFAULT_DELEGATE_PATTERNS).each { |m|
549         @delegate_list[m.intern] << botmodule
550       }
551       mark_priorities_dirty
552     end
553
554     # Returns an array of the loaded plugins
555     def core_modules
556       @botmodules[:CoreBotModule]
557     end
558
559     # Returns an array of the loaded plugins
560     def plugins
561       @botmodules[:Plugin]
562     end
563
564     # Returns a hash of the registered message prefixes and associated
565     # plugins
566     def commands
567       @commandmappers
568     end
569
570     # Tells the PluginManager that the next time it delegates an event, it
571     # should sort the modules by priority
572     def mark_priorities_dirty
573       @sorted_modules = nil
574     end
575
576     # Makes a string of error _err_ by adding text _str_
577     def report_error(str, err)
578       ([str, err.inspect] + err.backtrace).join("\n")
579     end
580
581     # This method is the one that actually loads a module from the
582     # file _fname_
583     #
584     # _desc_ is a simple description of what we are loading
585     # (plugin/botmodule/whatever) for error reporting
586     #
587     # It returns the Symbol :loaded on success, and an Exception
588     # on failure
589     #
590     def load_botmodule_file(fname, desc=nil)
591       # create a new, anonymous module to "house" the plugin
592       # the idea here is to prevent namespace pollution. perhaps there
593       # is another way?
594       plugin_module = Module.new
595       
596       # each plugin uses its own textdomain, we bind it automatically here
597       bindtextdomain_to(plugin_module, "rbot-#{File.basename(fname, '.rb')}")
598
599       desc = desc.to_s + " " if desc
600
601       begin
602         plugin_string = IO.read(fname)
603         debug "loading #{desc}#{fname}"
604         plugin_module.module_eval(plugin_string, fname)
605
606         # this sets a BOTMODULE_FNAME constant in all BotModule
607         # classes defined in the module. This allows us to know
608         # the filename the plugin was declared in from outside
609         # the plugin itself (from within, a __FILE__ would work.)
610         plugin_module.constants.each do |const|
611           cls = plugin_module.const_get(const)
612           if cls.is_a? Class and cls < BotModule
613             cls.const_set("BOTMODULE_FNAME", fname)
614           end
615         end
616
617         return :loaded
618       rescue Exception => err
619         # rescue TimeoutError, StandardError, NameError, LoadError, SyntaxError => err
620         error report_error("#{desc}#{fname} load failed", err)
621         bt = err.backtrace.select { |line|
622           line.match(/^(\(eval\)|#{fname}):\d+/)
623         }
624         bt.map! { |el|
625           el.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
626             "#{fname}#{$1}#{$3}"
627           }
628         }
629         msg = err.to_s.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
630           "#{fname}#{$1}#{$3}"
631         }
632         msg.gsub!(fname, File.basename(fname))
633         begin
634           newerr = err.class.new(msg)
635         rescue ArgumentError => aerr_in_err
636           # Somebody should hang the ActiveSupport developers by their balls
637           # with barbed wire. Their MissingSourceFile extension to LoadError
638           # _expects_ a second argument, breaking the usual Exception interface
639           # (instead, the smart thing to do would have been to make the second
640           # parameter optional and run the code in the from_message method if
641           # it was missing).
642           # Anyway, we try to cope with this in the simplest possible way. On
643           # the upside, this new block can be extended to handle other similar
644           # idiotic approaches
645           if err.class.respond_to? :from_message
646             newerr = err.class.from_message(msg)
647           else
648             raise aerr_in_err
649           end
650         rescue NoMethodError => nmerr_in_err
651           # Another braindead extension to StandardError, OAuth2::Error,
652           # doesn't get a string as message, but a response
653           if err.respond_to? :response
654             newerr = err.class.new(err.response)
655           else
656             raise nmerr_in_err
657           end
658         end
659         newerr.set_backtrace(bt)
660         return newerr
661       end
662     end
663     private :load_botmodule_file
664
665     # add one or more directories to the list of directories to
666     # load core modules from
667     def add_core_module_dir(*dirlist)
668       @core_module_dirs += dirlist
669       debug "Core module loading paths: #{@core_module_dirs.join(', ')}"
670     end
671
672     # add one or more directories to the list of directories to
673     # load plugins from
674     def add_plugin_dir(*dirlist)
675       @plugin_dirs += dirlist
676       debug "Plugin loading paths: #{@plugin_dirs.join(', ')}"
677     end
678
679     def clear_botmodule_dirs
680       @core_module_dirs.clear
681       @plugin_dirs.clear
682       debug "Core module and plugin loading paths cleared"
683     end
684
685     def scan_botmodules(opts={})
686       type = opts[:type]
687       processed = Hash.new
688
689       case type
690       when :core
691         dirs = @core_module_dirs
692       when :plugins
693         dirs = @plugin_dirs
694
695         @bot.config['plugins.blacklist'].each { |p|
696           pn = p + ".rb"
697           processed[pn.intern] = :blacklisted
698         }
699
700         whitelist = @bot.config['plugins.whitelist'].map { |p|
701           p + ".rb"
702         }
703       end
704
705       dirs.each do |dir|
706         next unless FileTest.directory?(dir)
707         d = Dir.new(dir)
708         d.sort.each do |file|
709           next unless file =~ /\.rb$/
710           next if file =~ /^\./
711
712           case type
713           when :plugins
714             if !whitelist.empty? && !whitelist.include?(file)
715               @ignored << {:name => file, :dir => dir, :reason => :"not whitelisted" }
716               next
717             elsif processed.has_key?(file.intern)
718               @ignored << {:name => file, :dir => dir, :reason => processed[file.intern]}
719               next
720             end
721
722             if(file =~ /^(.+\.rb)\.disabled$/)
723               # GB: Do we want to do this? This means that a disabled plugin in a directory
724               #     will disable in all subsequent directories. This was probably meant
725               #     to be used before plugins.blacklist was implemented, so I think
726               #     we don't need this anymore
727               processed[$1.intern] = :disabled
728               @ignored << {:name => $1, :dir => dir, :reason => processed[$1.intern]}
729               next
730             end
731           end
732
733           begin
734             did_it = load_botmodule_file("#{dir}/#{file}", "plugin")
735           rescue Exception => e
736             error e
737             did_it = e
738           end
739
740           case did_it
741           when Symbol
742             processed[file.intern] = did_it
743           when Exception
744             @failed << { :name => file, :dir => dir, :reason => did_it }
745           end
746         end
747       end
748     end
749
750     # load plugins from pre-assigned list of directories
751     def scan
752       @failed.clear
753       @ignored.clear
754       @delegate_list.clear
755
756       scan_botmodules(:type => :core)
757       scan_botmodules(:type => :plugins)
758
759       debug "finished loading plugins: #{status(true)}"
760       mark_priorities_dirty
761     end
762
763     # call the save method for each active plugin
764     #
765     # :botmodule ::
766     #   optional instance of a botmodule to save
767     def save(botmodule=nil)
768       if botmodule
769         botmodule.flush_registry
770         botmodule.save          
771       else
772         delegate 'flush_registry'
773         delegate 'save'
774       end
775     end
776
777     # call the cleanup method for each active plugin
778     #
779     # :botmodule ::
780     #   optional instance of a botmodule to cleanup
781     def cleanup(botmodule=nil)
782       if botmodule
783         botmodule.cleanup
784       else
785         delegate 'cleanup'
786       end
787       reset_botmodule_lists(botmodule)
788     end
789
790     # drops botmodules and rescan botmodules on disk
791     # calls save and cleanup for each botmodule before dropping them
792     # a optional _botmodule_ argument might specify a botmodule 
793     # instance that should be reloaded
794     #
795     # :botmodule ::
796     #   instance of the botmodule to rescan
797     def rescan(botmodule=nil)
798       save(botmodule)
799       cleanup(botmodule)
800       if botmodule
801         @failed.clear
802         @ignored.clear
803         filename = botmodule.class::BOTMODULE_FNAME
804         err = load_botmodule_file(filename, "plugin")
805         if err.is_a? Exception
806           @failed << { :name => botmodule.to_s,
807                        :dir => File.dirname(filename), :reason => err }
808         end
809       else
810         scan
811       end
812     end
813
814     def status(short=false)
815       output = []
816       if self.core_length > 0
817         if short
818           output << n_("%{count} core module loaded", "%{count} core modules loaded",
819                     self.core_length) % {:count => self.core_length}
820         else
821           output <<  n_("%{count} core module: %{list}",
822                      "%{count} core modules: %{list}", self.core_length) %
823                      { :count => self.core_length,
824                        :list => core_modules.collect{ |p| p.name}.sort.join(", ") }
825         end
826       else
827         output << _("no core botmodules loaded")
828       end
829       # Active plugins first
830       if(self.length > 0)
831         if short
832           output << n_("%{count} plugin loaded", "%{count} plugins loaded",
833                        self.length) % {:count => self.length}
834         else
835           output << n_("%{count} plugin: %{list}",
836                        "%{count} plugins: %{list}", self.length) %
837                    { :count => self.length,
838                      :list => plugins.collect{ |p| p.name}.sort.join(", ") }
839         end
840       else
841         output << "no plugins active"
842       end
843       # Ignored plugins next
844       unless @ignored.empty? or @failures_shown
845         if short
846           output << n_("%{highlight}%{count} plugin ignored%{highlight}",
847                        "%{highlight}%{count} plugins ignored%{highlight}",
848                        @ignored.length) %
849                     { :count => @ignored.length, :highlight => Underline }
850         else
851           output << n_("%{highlight}%{count} plugin ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
852                        "%{highlight}%{count} plugins ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
853                        @ignored.length) %
854                     { :count => @ignored.length, :highlight => Underline,
855                       :bold => Bold, :command => "help ignored plugins"}
856         end
857       end
858       # Failed plugins next
859       unless @failed.empty? or @failures_shown
860         if short
861           output << n_("%{highlight}%{count} plugin failed to load%{highlight}",
862                        "%{highlight}%{count} plugins failed to load%{highlight}",
863                        @failed.length) %
864                     { :count => @failed.length, :highlight => Reverse }
865         else
866           output << n_("%{highlight}%{count} plugin failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
867                        "%{highlight}%{count} plugins failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
868                        @failed.length) %
869                     { :count => @failed.length, :highlight => Reverse,
870                       :bold => Bold, :command => "help failed plugins"}
871         end
872       end
873       output.join '; '
874     end
875
876     # returns the last logged failure (if present) of a botmodule
877     #
878     # :name ::
879     #   name of the botmodule
880     def botmodule_failure(name)
881       failure = @failed.find { |f| f[:name] == name }
882       if failure
883         "%{exception}: %{reason}" % {
884           :exception => failure[:reason].class,
885           :reason => failure[:reason]
886         }
887       end
888     end
889
890     # return list of help topics (plugin names)
891     def helptopics
892       rv = status
893       @failures_shown = true
894       rv
895     end
896
897     def length
898       plugins.length
899     end
900
901     def core_length
902       core_modules.length
903     end
904
905     # return help for +topic+ (call associated plugin's help method)
906     def help(topic="")
907       case topic
908       when /fail(?:ed)?\s*plugins?.*(trace(?:back)?s?)?/
909         # debug "Failures: #{@failed.inspect}"
910         return _("no plugins failed to load") if @failed.empty?
911         return @failed.collect { |p|
912           _('%{highlight}%{plugin}%{highlight} in %{dir} failed with error %{exception}: %{reason}') % {
913               :highlight => Bold, :plugin => p[:name], :dir => p[:dir],
914               :exception => p[:reason].class, :reason => p[:reason],
915           } + if $1 && !p[:reason].backtrace.empty?
916                 _('at %{backtrace}') % {:backtrace => p[:reason].backtrace.join(', ')}
917               else
918                 ''
919               end
920         }.join("\n")
921       when /ignored?\s*plugins?/
922         return _('no plugins were ignored') if @ignored.empty?
923
924         tmp = Hash.new
925         @ignored.each do |p|
926           reason = p[:loaded] ? _('overruled by previous') : _(p[:reason].to_s)
927           ((tmp[p[:dir]] ||= Hash.new)[reason] ||= Array.new).push(p[:name])
928         end
929
930         return tmp.map do |dir, reasons|
931           # FIXME get rid of these string concatenations to make gettext easier
932           s = reasons.map { |r, list|
933             list.map { |_| _.sub(/\.rb$/, '') }.join(', ') + " (#{r})"
934           }.join('; ')
935           "in #{dir}: #{s}"
936         end.join('; ')
937       when /^(\S+)\s*(.*)$/
938         key = $1
939         params = $2
940
941         # Let's see if we can match a plugin by the given name
942         (core_modules + plugins).each { |p|
943           next unless p.name == key
944           begin
945             return p.help(key, params)
946           rescue Exception => err
947             #rescue TimeoutError, StandardError, NameError, SyntaxError => err
948             error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
949           end
950         }
951
952         # Nope, let's see if it's a command, and ask for help at the corresponding botmodule
953         k = key.to_sym
954         if commands.has_key?(k)
955           p = commands[k][:botmodule]
956           begin
957             return p.help(key, params)
958           rescue Exception => err
959             #rescue TimeoutError, StandardError, NameError, SyntaxError => err
960             error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
961           end
962         end
963       end
964       return false
965     end
966
967     def sort_modules
968       @sorted_modules = (core_modules + plugins).sort do |a, b|
969         a.priority <=> b.priority
970       end || []
971
972       @delegate_list.each_value do |list|
973         list.sort! {|a,b| a.priority <=> b.priority}
974       end
975     end
976
977     # delegate(method, [m,] opts={})
978     #
979     # see if each plugin handles _method_, and if so, call it, passing
980     # _m_ as a parameter (if present). BotModules are called in order of
981     # priority from lowest to highest.
982     #
983     # If the passed _m_ is a BasicUserMessage and is marked as #ignored?, it
984     # will only be delegated to plugins with negative priority. Conversely, if
985     # it's a fake message (see BotModule#fake_message), it will only be
986     # delegated to plugins with positive priority.
987     #
988     # Note that _m_ can also be an exploded Array, but in this case the last
989     # element of it cannot be a Hash, or it will be interpreted as the options
990     # Hash for delegate itself. The last element can be a subclass of a Hash, though.
991     # To be on the safe side, you can add an empty Hash as last parameter for delegate
992     # when calling it with an exploded Array:
993     #   @bot.plugins.delegate(method, *(args.push Hash.new))
994     #
995     # Currently supported options are the following:
996     # :above ::
997     #   if specified, the delegation will only consider plugins with a priority
998     #   higher than the specified value
999     # :below ::
1000     #   if specified, the delegation will only consider plugins with a priority
1001     #   lower than the specified value
1002     #
1003     def delegate(method, *args)
1004       # if the priorities order of the delegate list is dirty,
1005       # meaning some modules have been added or priorities have been
1006       # changed, then the delegate list will need to be sorted before
1007       # delegation.  This should always be true for the first delegation.
1008       sort_modules unless @sorted_modules
1009
1010       opts = {}
1011       opts.merge(args.pop) if args.last.class == Hash
1012
1013       m = args.first
1014       if BasicUserMessage === m
1015         # ignored messages should not be delegated
1016         # to plugins with positive priority
1017         opts[:below] ||= 0 if m.ignored?
1018         # fake messages should not be delegated
1019         # to plugins with negative priority
1020         opts[:above] ||= 0 if m.recurse_depth > 0
1021       end
1022
1023       above = opts[:above]
1024       below = opts[:below]
1025
1026       # debug "Delegating #{method.inspect}"
1027       ret = Array.new
1028       if method.match(DEFAULT_DELEGATE_PATTERNS)
1029         debug "fast-delegating #{method}"
1030         m = method.to_sym
1031         debug "no-one to delegate to" unless @delegate_list.has_key?(m)
1032         return [] unless @delegate_list.has_key?(m)
1033         @delegate_list[m].each { |p|
1034           begin
1035             prio = p.priority
1036             unless (above and above >= prio) or (below and below <= prio)
1037               ret.push p.send(method, *args)
1038             end
1039           rescue Exception => err
1040             raise if err.kind_of?(SystemExit)
1041             error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
1042           end
1043         }
1044       else
1045         debug "slow-delegating #{method}"
1046         @sorted_modules.each { |p|
1047           if(p.respond_to? method)
1048             begin
1049               # debug "#{p.botmodule_class} #{p.name} responds"
1050               prio = p.priority
1051               unless (above and above >= prio) or (below and below <= prio)
1052                 ret.push p.send(method, *args)
1053               end
1054             rescue Exception => err
1055               raise if err.kind_of?(SystemExit)
1056               error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
1057             end
1058           end
1059         }
1060       end
1061       return ret
1062       # debug "Finished delegating #{method.inspect}"
1063     end
1064
1065     # see if we have a plugin that wants to handle this message, if so, pass
1066     # it to the plugin and return true, otherwise false
1067     def privmsg(m)
1068       debug "Delegating privmsg #{m.inspect} with pluginkey #{m.plugin.inspect}"
1069       return unless m.plugin
1070       k = m.plugin.to_sym
1071       if commands.has_key?(k)
1072         p = commands[k][:botmodule]
1073         a = commands[k][:auth]
1074         # We check here for things that don't check themselves
1075         # (e.g. mapped things)
1076         debug "Checking auth ..."
1077         if a.nil? || @bot.auth.allow?(a, m.source, m.replyto)
1078           debug "Checking response ..."
1079           if p.respond_to?("privmsg")
1080             begin
1081               debug "#{p.botmodule_class} #{p.name} responds"
1082               p.privmsg(m)
1083             rescue Exception => err
1084               raise if err.kind_of?(SystemExit)
1085               error report_error("#{p.botmodule_class} #{p.name} privmsg() failed:", err)
1086             end
1087             debug "Successfully delegated #{m.inspect}"
1088             return true
1089           else
1090             debug "#{p.botmodule_class} #{p.name} is registered, but it doesn't respond to privmsg()"
1091           end
1092         else
1093           debug "#{p.botmodule_class} #{p.name} is registered, but #{m.source} isn't allowed to call #{m.plugin.inspect} on #{m.replyto}"
1094         end
1095       else
1096         debug "Command #{k} isn't handled"
1097       end
1098       return false
1099     end
1100
1101     # delegate IRC messages, by delegating 'listen' first, and the actual method
1102     # afterwards. Delegating 'privmsg' also delegates ctcp_listen and message
1103     # as appropriate.
1104     def irc_delegate(method, m)
1105       delegate('listen', m)
1106       if method.to_sym == :privmsg
1107         delegate('ctcp_listen', m) if m.ctcp
1108         delegate('message', m)
1109         privmsg(m) if m.address? and not m.ignored?
1110         delegate('unreplied', m) unless m.replied
1111       else
1112         delegate(method, m)
1113       end
1114     end
1115   end
1116
1117   # Returns the only PluginManagerClass instance
1118   def Plugins.manager
1119     return PluginManagerClass.instance
1120   end
1121
1122 end
1123 end
1124 end