4 # :title: rbot plugin management
10 Config.register Config::ArrayValue.new('plugins.blacklist',
11 :default => [], :wizard => false, :requires_rescan => true,
12 :desc => "Plugins that should not be loaded")
14 require 'rbot/messagemapper'
17 BotModule is the base class for the modules that enhance the rbot
18 functionality. Rather than subclassing BotModule, however, one should
19 subclass either CoreBotModule (reserved for system modules) or Plugin
22 A BotModule interacts with Irc events by defining one or more of the following
23 methods, which get called as appropriate when the corresponding Irc event
26 map(template, options)::
27 map!(template, options)::
28 map is the new, cleaner way to respond to specific message formats without
29 littering your plugin code with regexps, and should be used instead of
30 #register() and #privmsg() (see below) when possible.
32 The difference between map and map! is that map! will not register the new
33 command as an alternative name for the plugin.
37 plugin.map 'karmastats', :action => 'karma_stats'
39 # while in the plugin...
40 def karma_stats(m, params)
44 # the default action is the first component
47 # attributes can be pulled out of the match string
48 plugin.map 'karma for :key'
49 plugin.map 'karma :key'
51 # while in the plugin...
54 m.reply 'karma for #{item}'
57 # you can setup defaults, to make parameters optional
58 plugin.map 'karma :key', :defaults => {:key => 'defaultvalue'}
60 # the default auth check is also against the first component
61 # but that can be changed
62 plugin.map 'karmastats', :auth => 'karma'
64 # maps can be restricted to public or private message:
65 plugin.map 'karmastats', :private => false
66 plugin.map 'karmastats', :public => false
68 See MessageMapper#map for more information on the template format and the
72 Called for all messages of any type. To
73 differentiate them, use message.kind_of? It'll be
74 either a PrivMessage, NoticeMessage, KickMessage,
75 QuitMessage, PartMessage, JoinMessage, NickMessage,
78 ctcp_listen(UserMessage)::
79 Called for all messages that contain a CTCP command.
80 Use message.ctcp to get the CTCP command, and
81 message.message to get the parameter string. To reply,
82 use message.ctcp_reply, which sends a private NOTICE
85 message(PrivMessage)::
86 Called for all PRIVMSG. Hook on this method if you
87 need to handle PRIVMSGs regardless of whether they are
88 addressed to the bot or not, and regardless of
90 privmsg(PrivMessage)::
91 Called for a PRIVMSG if the first word matches one
92 the plugin #register()ed for. Use m.plugin to get
93 that word and m.params for the rest of the message,
96 unreplied(PrivMessage)::
97 Called for a PRIVMSG which has not been replied to.
100 Called when a user (or the bot) is kicked from a
101 channel the bot is in.
103 invite(InviteMessage)::
104 Called when the bot is invited to a channel.
107 Called when a user (or the bot) joins a channel
110 Called when a user (or the bot) parts a channel
113 Called when a user (or the bot) quits IRC
116 Called when a user (or the bot) changes Nick
117 topic(TopicMessage)::
118 Called when a user (or the bot) changes a channel
121 connect:: Called when a server is joined successfully, but
122 before autojoin channels are joined (no params)
124 set_language(String)::
125 Called when the user sets a new language
126 whose name is the given String
128 save:: Called when you are required to save your plugin's
129 state, if you maintain data between sessions
131 cleanup:: called before your plugin is "unloaded", prior to a
132 plugin reload or bot quit - close any open
133 files/connections or flush caches here
140 # the plugin registry
141 attr_reader :registry
143 # the message map handler
146 # Initialise your bot module. Always call super if you override this method,
147 # as important variables are set up for you:
152 # the botmodule's registry, which can be used to store permanent data
153 # (see Registry::Accessor for additional documentation)
155 # Other instance variables which are defined and should not be overwritten
156 # byt the user, but aren't usually accessed directly, are:
159 # the plugins manager instance
160 # @botmodule_triggers::
161 # an Array of words this plugin #register()ed itself for
163 # the MessageMapper that handles this plugin's maps
166 @manager = Plugins::manager
169 @botmodule_triggers = Array.new
171 @handler = MessageMapper.new(self)
172 @registry = Registry::Accessor.new(@bot, self.class.to_s.gsub(/^.*::/, ""))
174 @manager.add_botmodule(self)
175 if self.respond_to?('set_language')
176 self.set_language(@bot.lang.language)
180 # Changing the value of @priority directly will cause problems,
181 # Please use priority=.
186 # Returns the symbol :BotModule
191 # Method called to flush the registry, thus ensuring that the botmodule's permanent
192 # data is committed to disk
195 # debug "Flushing #{@registry}"
199 # Method called to cleanup before the plugin is unloaded. If you overload
200 # this method to handle additional cleanup tasks, remember to call super()
201 # so that the default cleanup actions are taken care of as well.
204 # debug "Closing #{@registry}"
208 # Handle an Irc::PrivMessage for which this BotModule has a map. The method
209 # is called automatically and there is usually no need to call it
216 # Signal to other BotModules that an even happened.
218 def call_event(ev, *args)
219 @bot.plugins.delegate('event_' + ev.to_s.gsub(/[^\w\?!]+/, '_'), *args)
222 # call-seq: map(template, options)
224 # This is the preferred way to register the BotModule so that it
225 # responds to appropriately-formed messages on Irc.
231 # call-seq: map!(template, options)
233 # This is the same as map but doesn't register the new command
234 # as an alternative name for the plugin.
240 # Auxiliary method called by #map and #map!
241 def do_map(silent, *args)
242 @handler.map(self, *args)
246 self.register name, :auth => nil, :hidden => silent
247 @manager.register_map(self, map)
248 unless self.respond_to?('privmsg')
249 def self.privmsg(m) #:nodoc:
255 # Sets the default auth for command path _cmd_ to _val_ on channel _chan_:
256 # usually _chan_ is either "*" for everywhere, public and private (in which
257 # case it can be omitted) or "?" for private communications
259 def default_auth(cmd, val, chan="*")
266 Auth::defaultbotuser.set_default_permission(propose_default_path(c), val)
269 # Gets the default command path which would be given to command _cmd_
270 def propose_default_path(cmd)
271 [name, cmd].compact.join("::")
274 # Return an identifier for this plugin, defaults to a list of the message
275 # prefixes handled (used for error messages etc)
277 self.class.to_s.downcase.sub(/^#<module:.*?>::/,"").sub(/(plugin|module)?$/,"")
290 # Return a help string for your module. For complex modules, you may wish
291 # to break your help into topics, and return a list of available topics if
292 # +topic+ is nil. +plugin+ is passed containing the matching prefix for
293 # this message - if your plugin handles multiple prefixes, make sure you
294 # return the correct help for the prefix requested
295 def help(plugin, topic)
299 # Register the plugin as a handler for messages prefixed _cmd_.
301 # This can be called multiple times for a plugin to handle multiple message
304 # This command is now superceded by the #map() command, which should be used
305 # instead whenever possible.
307 def register(cmd, opts={})
308 raise ArgumentError, "Second argument must be a hash!" unless opts.kind_of?(Hash)
309 who = @manager.who_handles?(cmd)
311 raise "Command #{cmd} is already handled by #{who.botmodule_class} #{who}" if who != self
314 if opts.has_key?(:auth)
315 @manager.register(self, cmd, opts[:auth])
317 @manager.register(self, cmd, propose_default_path(cmd))
319 @botmodule_triggers << cmd unless opts.fetch(:hidden, false)
322 # Default usage method provided as a utility for simple plugins. The
323 # MessageMapper uses 'usage' as its default fallback method.
325 def usage(m, params = {})
326 m.reply(_("incorrect usage, ask for help using '%{command}'") % {:command => "#{@bot.nick}: help #{m.plugin}"})
329 # Define the priority of the module. During event delegation, lower
330 # priority modules will be called first. Default priority is 1
334 @bot.plugins.mark_priorities_dirty
339 # A CoreBotModule is a BotModule that provides core functionality.
341 # This class should not be used by user plugins, as it's reserved for system
342 # plugins such as the ones that handle authentication, configuration and basic
345 class CoreBotModule < BotModule
351 # A Plugin is a BotModule that provides additional functionality.
353 # A user-defined plugin should subclass this, and then define any of the
354 # methods described in the documentation for BotModule to handle interaction
357 class Plugin < BotModule
363 # Singleton to manage multiple plugins and delegate messages to them for
365 class PluginManagerClass
368 attr_reader :botmodules
371 # This is the list of patterns commonly delegated to plugins.
372 # A fast delegation lookup is enabled for them.
373 DEFAULT_DELEGATE_PATTERNS = %r{^(?:
375 listen|ctcp_listen|privmsg|unreplied|
377 save|cleanup|flush_registry|
383 :CoreBotModule => [],
387 @names_hash = Hash.new
388 @commandmappers = Hash.new
391 # modules will be sorted on first delegate call
392 @sorted_modules = nil
394 @delegate_list = Hash.new { |h, k|
407 ret = self.to_s[0..-2]
408 ret << ' corebotmodules='
409 ret << @botmodules[:CoreBotModule].map { |m|
413 ret << @botmodules[:Plugin].map { |m|
419 # Reset lists of botmodules
420 def reset_botmodule_lists
421 @botmodules[:CoreBotModule].clear
422 @botmodules[:Plugin].clear
424 @commandmappers.clear
426 @failures_shown = false
429 # Associate with bot _bot_
430 def bot_associate(bot)
431 reset_botmodule_lists
435 # Returns the botmodule with the given _name_
437 @names_hash[name.to_sym]
440 # Returns +true+ if _cmd_ has already been registered as a command
441 def who_handles?(cmd)
442 return nil unless @commandmappers.has_key?(cmd.to_sym)
443 return @commandmappers[cmd.to_sym][:botmodule]
446 # Registers botmodule _botmodule_ with command _cmd_ and command path _auth_path_
447 def register(botmodule, cmd, auth_path)
448 raise TypeError, "First argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
449 @commandmappers[cmd.to_sym] = {:botmodule => botmodule, :auth => auth_path}
452 # Registers botmodule _botmodule_ with map _map_. This adds the map to the #maps hash
453 # which has three keys:
455 # botmodule:: the associated botmodule
456 # auth:: an array of auth keys checked by the map; the first is the full_auth_path of the map
457 # map:: the actual MessageTemplate object
460 def register_map(botmodule, map)
461 raise TypeError, "First argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
462 @maps[map.template] = { :botmodule => botmodule, :auth => [map.options[:full_auth_path]], :map => map }
465 def add_botmodule(botmodule)
466 raise TypeError, "Argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
467 kl = botmodule.botmodule_class
468 if @names_hash.has_key?(botmodule.to_sym)
469 case self[botmodule].botmodule_class
471 raise "#{kl} #{botmodule} already registered!"
473 raise "#{self[botmodule].botmodule_class} #{botmodule} already registered, cannot re-register as #{kl}"
476 @botmodules[kl] << botmodule
477 @names_hash[botmodule.to_sym] = botmodule
480 # Returns an array of the loaded plugins
482 @botmodules[:CoreBotModule]
485 # Returns an array of the loaded plugins
490 # Returns a hash of the registered message prefixes and associated
496 # Tells the PluginManager that the next time it delegates an event, it
497 # should sort the modules by priority
498 def mark_priorities_dirty
499 @sorted_modules = nil
502 # Makes a string of error _err_ by adding text _str_
503 def report_error(str, err)
504 ([str, err.inspect] + err.backtrace).join("\n")
507 # This method is the one that actually loads a module from the
510 # _desc_ is a simple description of what we are loading (plugin/botmodule/whatever)
512 # It returns the Symbol :loaded on success, and an Exception
515 def load_botmodule_file(fname, desc=nil)
516 # create a new, anonymous module to "house" the plugin
517 # the idea here is to prevent namespace pollution. perhaps there
519 plugin_module = Module.new
521 desc = desc.to_s + " " if desc
524 plugin_string = IO.readlines(fname).join("")
525 debug "loading #{desc}#{fname}"
526 plugin_module.module_eval(plugin_string, fname)
528 rescue Exception => err
529 # rescue TimeoutError, StandardError, NameError, LoadError, SyntaxError => err
530 error report_error("#{desc}#{fname} load failed", err)
531 bt = err.backtrace.select { |line|
532 line.match(/^(\(eval\)|#{fname}):\d+/)
535 el.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
539 msg = err.to_str.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
542 newerr = err.class.new(msg)
543 newerr.set_backtrace(bt)
547 private :load_botmodule_file
549 # add one or more directories to the list of directories to
550 # load botmodules from
552 # TODO find a way to specify necessary plugins which _must_ be loaded
554 def add_botmodule_dir(*dirlist)
556 debug "Botmodule loading path: #{@dirs.join(', ')}"
559 def clear_botmodule_dirs
561 debug "Botmodule loading path cleared"
564 # load plugins from pre-assigned list of directories
572 @bot.config['plugins.blacklist'].each { |p|
574 processed[pn.intern] = :blacklisted
579 if(FileTest.directory?(dir))
583 next if(file =~ /^\./)
585 if processed.has_key?(file.intern)
586 @ignored << {:name => file, :dir => dir, :reason => processed[file.intern]}
590 if(file =~ /^(.+\.rb)\.disabled$/)
591 # GB: Do we want to do this? This means that a disabled plugin in a directory
592 # will disable in all subsequent directories. This was probably meant
593 # to be used before plugins.blacklist was implemented, so I think
594 # we don't need this anymore
595 processed[$1.intern] = :disabled
596 @ignored << {:name => $1, :dir => dir, :reason => processed[$1.intern]}
600 next unless(file =~ /\.rb$/)
602 did_it = load_botmodule_file("#{dir}/#{file}", "plugin")
605 processed[file.intern] = did_it
607 @failed << { :name => file, :dir => dir, :reason => did_it }
613 debug "finished loading plugins: #{status(true)}"
614 (core_modules + plugins).each { |p|
615 p.methods.grep(DEFAULT_DELEGATE_PATTERNS).each { |m|
616 @delegate_list[m.intern] << p
621 # call the save method for each active plugin
623 delegate 'flush_registry'
627 # call the cleanup method for each active plugin
630 reset_botmodule_lists
633 # drop all plugins and rescan plugins on disk
634 # calls save and cleanup for each plugin before dropping them
641 def status(short=false)
643 if self.core_length > 0
645 output << n_("%{count} core module loaded", "%{count} core modules loaded",
646 self.core_length) % {:count => self.core_length}
648 output << n_("%{count} core module: %{list}",
649 "%{count} core modules: %{list}", self.core_length) %
650 { :count => self.core_length,
651 :list => core_modules.collect{ |p| p.name}.sort.join(", ") }
654 output << _("no core botmodules loaded")
656 # Active plugins first
659 output << n_("%{count} plugin loaded", "%{count} plugins loaded",
660 self.length) % {:count => self.length}
662 output << n_("%{count} plugin: %{list}",
663 "%{count} plugins: %{list}", self.length) %
664 { :count => self.length,
665 :list => plugins.collect{ |p| p.name}.sort.join(", ") }
668 output << "no plugins active"
670 # Ignored plugins next
671 unless @ignored.empty? or @failures_shown
673 output << n_("%{highlight}%{count} plugin ignored%{highlight}",
674 "%{highlight}%{count} plugins ignored%{highlight}",
676 { :count => @ignored.length, :highlight => Underline }
678 output << n_("%{highlight}%{count} plugin ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
679 "%{highlight}%{count} plugins ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
681 { :count => @ignored.length, :highlight => Underline,
682 :bold => Bold, :command => "help ignored plugins"}
685 # Failed plugins next
686 unless @failed.empty? or @failures_shown
688 output << n_("%{highlight}%{count} plugin failed to load%{highlight}",
689 "%{highlight}%{count} plugins failed to load%{highlight}",
691 { :count => @failed.length, :highlight => Reverse }
693 output << n_("%{highlight}%{count} plugin failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
694 "%{highlight}%{count} plugins failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
696 { :count => @failed.length, :highlight => Reverse,
697 :bold => Bold, :command => "help failed plugins"}
703 # return list of help topics (plugin names)
706 @failures_shown = true
718 # return help for +topic+ (call associated plugin's help method)
721 when /fail(?:ed)?\s*plugins?.*(trace(?:back)?s?)?/
722 # debug "Failures: #{@failed.inspect}"
723 return _("no plugins failed to load") if @failed.empty?
724 return @failed.collect { |p|
725 _('%{highlight}%{plugin}%{highlight} in %{dir} failed with error %{exception}: %{reason}') % {
726 :highlight => Bold, :plugin => p[:name], :dir => p[:dir],
727 :exception => p[:reason].class, :reason => p[:reason],
728 } + if $1 && !p[:reason].backtrace.empty?
729 _('at %{backtrace}') % {:backtrace => p[:reason].backtrace.join(', ')}
734 when /ignored?\s*plugins?/
735 return _('no plugins were ignored') if @ignored.empty?
739 reason = p[:loaded] ? _('overruled by previous') : _(p[:reason].to_s)
740 ((tmp[p[:dir]] ||= Hash.new)[reason] ||= Array.new).push(p[:name])
743 return tmp.map do |dir, reasons|
744 # FIXME get rid of these string concatenations to make gettext easier
745 s = reasons.map { |r, list|
746 list.map { |_| _.sub(/\.rb$/, '') }.join(', ') + " (#{r})"
750 when /^(\S+)\s*(.*)$/
754 # Let's see if we can match a plugin by the given name
755 (core_modules + plugins).each { |p|
756 next unless p.name == key
758 return p.help(key, params)
759 rescue Exception => err
760 #rescue TimeoutError, StandardError, NameError, SyntaxError => err
761 error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
765 # Nope, let's see if it's a command, and ask for help at the corresponding botmodule
767 if commands.has_key?(k)
768 p = commands[k][:botmodule]
770 return p.help(key, params)
771 rescue Exception => err
772 #rescue TimeoutError, StandardError, NameError, SyntaxError => err
773 error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
781 @sorted_modules = (core_modules + plugins).sort do |a, b|
782 a.priority <=> b.priority
785 @delegate_list.each_value do |list|
786 list.sort! {|a,b| a.priority <=> b.priority}
790 # see if each plugin handles +method+, and if so, call it, passing
791 # +message+ as a parameter
792 def delegate(method, *args)
793 # if the priorities order of the delegate list is dirty,
794 # meaning some modules have been added or priorities have been
795 # changed, then the delegate list will need to be sorted before
796 # delegation. This should always be true for the first delegation.
797 sort_modules unless @sorted_modules
799 # debug "Delegating #{method.inspect}"
801 if method.match(DEFAULT_DELEGATE_PATTERNS)
802 debug "fast-delegating #{method}"
804 debug "no-one to delegate to" unless @delegate_list.has_key?(m)
805 return [] unless @delegate_list.has_key?(m)
806 @delegate_list[m].each { |p|
808 ret.push p.send(method, *args)
809 rescue Exception => err
810 raise if err.kind_of?(SystemExit)
811 error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
812 raise if err.kind_of?(BDB::Fatal)
816 debug "slow-delegating #{method}"
817 @sorted_modules.each { |p|
818 if(p.respond_to? method)
820 # debug "#{p.botmodule_class} #{p.name} responds"
821 ret.push p.send(method, *args)
822 rescue Exception => err
823 raise if err.kind_of?(SystemExit)
824 error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
825 raise if err.kind_of?(BDB::Fatal)
831 # debug "Finished delegating #{method.inspect}"
834 # see if we have a plugin that wants to handle this message, if so, pass
835 # it to the plugin and return true, otherwise false
837 # debug "Delegating privmsg #{m.message.inspect} from #{m.source} to #{m.replyto} with pluginkey #{m.plugin.inspect}"
838 return unless m.plugin
840 if commands.has_key?(k)
841 p = commands[k][:botmodule]
842 a = commands[k][:auth]
843 # We check here for things that don't check themselves
844 # (e.g. mapped things)
845 # debug "Checking auth ..."
846 if a.nil? || @bot.auth.allow?(a, m.source, m.replyto)
847 # debug "Checking response ..."
848 if p.respond_to?("privmsg")
850 # debug "#{p.botmodule_class} #{p.name} responds"
852 rescue Exception => err
853 raise if err.kind_of?(SystemExit)
854 error report_error("#{p.botmodule_class} #{p.name} privmsg() failed:", err)
855 raise if err.kind_of?(BDB::Fatal)
857 # debug "Successfully delegated #{m.message}"
860 # debug "#{p.botmodule_class} #{p.name} is registered, but it doesn't respond to privmsg()"
863 # debug "#{p.botmodule_class} #{p.name} is registered, but #{m.source} isn't allowed to call #{m.plugin.inspect} on #{m.replyto}"
866 # debug "Finished delegating privmsg with key #{m.plugin.inspect}" + ( pl.empty? ? "" : " to #{pl.values.first[:botmodule].botmodule_class}s" )
868 # debug "Finished delegating privmsg with key #{m.plugin.inspect}"
871 # delegate IRC messages, by delegating 'listen' first, and the actual method
872 # afterwards. Delegating 'privmsg' also delegates ctcp_listen and message
874 def irc_delegate(method, m)
875 delegate('listen', m)
876 if method.to_sym == :privmsg
877 delegate('ctcp_listen', m) if m.ctcp
878 delegate('message', m)
879 privmsg(m) if m.address?
880 delegate('unreplied', m) unless m.replied
887 # Returns the only PluginManagerClass instance
889 return PluginManagerClass.instance