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 privmsg(PrivMessage)::
86 Called for a PRIVMSG if the first word matches one
87 the plugin #register()ed for. Use m.plugin to get
88 that word and m.params for the rest of the message,
91 unreplied(PrivMessage)::
92 Called for a PRIVMSG which has not been replied to.
95 Called when a user (or the bot) is kicked from a
96 channel the bot is in.
98 invite(InviteMessage)::
99 Called when the bot is invited to a channel.
102 Called when a user (or the bot) joins a channel
105 Called when a user (or the bot) parts a channel
108 Called when a user (or the bot) quits IRC
111 Called when a user (or the bot) changes Nick
112 topic(TopicMessage)::
113 Called when a user (or the bot) changes a channel
116 connect:: Called when a server is joined successfully, but
117 before autojoin channels are joined (no params)
119 set_language(String)::
120 Called when the user sets a new language
121 whose name is the given String
123 save:: Called when you are required to save your plugin's
124 state, if you maintain data between sessions
126 cleanup:: called before your plugin is "unloaded", prior to a
127 plugin reload or bot quit - close any open
128 files/connections or flush caches here
132 attr_reader :bot # the associated bot
133 attr_reader :registry # the plugin registry
134 attr_reader :handler # the message map handler
136 # Initialise your bot module. Always call super if you override this method,
137 # as important variables are set up for you:
142 # the botmodule's registry, which can be used to store permanent data
143 # (see Registry::Accessor for additional documentation)
145 # Other instance variables which are defined and should not be overwritten
146 # byt the user, but aren't usually accessed directly, are:
149 # the plugins manager instance
150 # @botmodule_triggers::
151 # an Array of words this plugin #register()ed itself for
153 # the MessageMapper that handles this plugin's maps
156 @manager = Plugins::manager
159 @botmodule_triggers = Array.new
161 @handler = MessageMapper.new(self)
162 @registry = Registry::Accessor.new(@bot, self.class.to_s.gsub(/^.*::/, ""))
164 @manager.add_botmodule(self)
165 if self.respond_to?('set_language')
166 self.set_language(@bot.lang.language)
170 # Returns the symbol :BotModule
175 # Method called to flush the registry, thus ensuring that the botmodule's permanent
176 # data is committed to disk
179 # debug "Flushing #{@registry}"
183 # Method called to cleanup before the plugin is unloaded. If you overload
184 # this method to handle additional cleanup tasks, remember to call super()
185 # so that the default cleanup actions are taken care of as well.
188 # debug "Closing #{@registry}"
192 # Handle an Irc::PrivMessage for which this BotModule has a map. The method
193 # is called automatically and there is usually no need to call it
200 # Signal to other BotModules that an even happened.
202 def call_event(ev, *args)
203 @bot.plugins.delegate('event_' + ev.to_s.gsub(/[^\w\?!]+/, '_'), *args)
206 # call-seq: map(template, options)
208 # This is the preferred way to register the BotModule so that it
209 # responds to appropriately-formed messages on Irc.
215 # call-seq: map!(template, options)
217 # This is the same as map but doesn't register the new command
218 # as an alternative name for the plugin.
224 # Auxiliary method called by #map and #map!
225 def do_map(silent, *args)
226 @handler.map(self, *args)
230 self.register name, :auth => nil, :hidden => silent
231 @manager.register_map(self, map)
232 unless self.respond_to?('privmsg')
233 def self.privmsg(m) #:nodoc:
239 # Sets the default auth for command path _cmd_ to _val_ on channel _chan_:
240 # usually _chan_ is either "*" for everywhere, public and private (in which
241 # case it can be omitted) or "?" for private communications
243 def default_auth(cmd, val, chan="*")
250 Auth::defaultbotuser.set_default_permission(propose_default_path(c), val)
253 # Gets the default command path which would be given to command _cmd_
254 def propose_default_path(cmd)
255 [name, cmd].compact.join("::")
258 # Return an identifier for this plugin, defaults to a list of the message
259 # prefixes handled (used for error messages etc)
261 self.class.to_s.downcase.sub(/^#<module:.*?>::/,"").sub(/(plugin|module)?$/,"")
274 # Return a help string for your module. For complex modules, you may wish
275 # to break your help into topics, and return a list of available topics if
276 # +topic+ is nil. +plugin+ is passed containing the matching prefix for
277 # this message - if your plugin handles multiple prefixes, make sure you
278 # return the correct help for the prefix requested
279 def help(plugin, topic)
283 # Register the plugin as a handler for messages prefixed _cmd_.
285 # This can be called multiple times for a plugin to handle multiple message
288 # This command is now superceded by the #map() command, which should be used
289 # instead whenever possible.
291 def register(cmd, opts={})
292 raise ArgumentError, "Second argument must be a hash!" unless opts.kind_of?(Hash)
293 who = @manager.who_handles?(cmd)
295 raise "Command #{cmd} is already handled by #{who.botmodule_class} #{who}" if who != self
298 if opts.has_key?(:auth)
299 @manager.register(self, cmd, opts[:auth])
301 @manager.register(self, cmd, propose_default_path(cmd))
303 @botmodule_triggers << cmd unless opts.fetch(:hidden, false)
306 # Default usage method provided as a utility for simple plugins. The
307 # MessageMapper uses 'usage' as its default fallback method.
309 def usage(m, params = {})
310 m.reply(_("incorrect usage, ask for help using '%{command}'") % {:command => "#{@bot.nick}: help #{m.plugin}"})
315 # A CoreBotModule is a BotModule that provides core functionality.
317 # This class should not be used by user plugins, as it's reserved for system
318 # plugins such as the ones that handle authentication, configuration and basic
321 class CoreBotModule < BotModule
327 # A Plugin is a BotModule that provides additional functionality.
329 # A user-defined plugin should subclass this, and then define any of the
330 # methods described in the documentation for BotModule to handle interaction
333 class Plugin < BotModule
339 # Singleton to manage multiple plugins and delegate messages to them for
341 class PluginManagerClass
344 attr_reader :botmodules
347 # This is the list of patterns commonly delegated to plugins.
348 # A fast delegation lookup is enabled for them.
349 DEFAULT_DELEGATE_PATTERNS = %r{^(?:
351 listen|ctcp_listen|privmsg|unreplied|
353 save|cleanup|flush_registry|
359 :CoreBotModule => [],
363 @names_hash = Hash.new
364 @commandmappers = Hash.new
366 @delegate_list = Hash.new { |h, k|
379 ret = self.to_s[0..-2]
380 ret << ' corebotmodules='
381 ret << @botmodules[:CoreBotModule].map { |m|
385 ret << @botmodules[:Plugin].map { |m|
391 # Reset lists of botmodules
392 def reset_botmodule_lists
393 @botmodules[:CoreBotModule].clear
394 @botmodules[:Plugin].clear
396 @commandmappers.clear
398 @failures_shown = false
401 # Associate with bot _bot_
402 def bot_associate(bot)
403 reset_botmodule_lists
407 # Returns the botmodule with the given _name_
409 @names_hash[name.to_sym]
412 # Returns +true+ if _cmd_ has already been registered as a command
413 def who_handles?(cmd)
414 return nil unless @commandmappers.has_key?(cmd.to_sym)
415 return @commandmappers[cmd.to_sym][:botmodule]
418 # Registers botmodule _botmodule_ with command _cmd_ and command path _auth_path_
419 def register(botmodule, cmd, auth_path)
420 raise TypeError, "First argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
421 @commandmappers[cmd.to_sym] = {:botmodule => botmodule, :auth => auth_path}
424 # Registers botmodule _botmodule_ with map _map_. This adds the map to the #maps hash
425 # which has three keys:
427 # botmodule:: the associated botmodule
428 # auth:: an array of auth keys checked by the map; the first is the full_auth_path of the map
429 # map:: the actual MessageTemplate object
432 def register_map(botmodule, map)
433 raise TypeError, "First argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
434 @maps[map.template] = { :botmodule => botmodule, :auth => [map.options[:full_auth_path]], :map => map }
437 def add_botmodule(botmodule)
438 raise TypeError, "Argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
439 kl = botmodule.botmodule_class
440 if @names_hash.has_key?(botmodule.to_sym)
441 case self[botmodule].botmodule_class
443 raise "#{kl} #{botmodule} already registered!"
445 raise "#{self[botmodule].botmodule_class} #{botmodule} already registered, cannot re-register as #{kl}"
448 @botmodules[kl] << botmodule
449 @names_hash[botmodule.to_sym] = botmodule
452 # Returns an array of the loaded plugins
454 @botmodules[:CoreBotModule]
457 # Returns an array of the loaded plugins
462 # Returns a hash of the registered message prefixes and associated
468 # Makes a string of error _err_ by adding text _str_
469 def report_error(str, err)
470 ([str, err.inspect] + err.backtrace).join("\n")
473 # This method is the one that actually loads a module from the
476 # _desc_ is a simple description of what we are loading (plugin/botmodule/whatever)
478 # It returns the Symbol :loaded on success, and an Exception
481 def load_botmodule_file(fname, desc=nil)
482 # create a new, anonymous module to "house" the plugin
483 # the idea here is to prevent namespace pollution. perhaps there
485 plugin_module = Module.new
487 desc = desc.to_s + " " if desc
490 plugin_string = IO.readlines(fname).join("")
491 debug "loading #{desc}#{fname}"
492 plugin_module.module_eval(plugin_string, fname)
494 rescue Exception => err
495 # rescue TimeoutError, StandardError, NameError, LoadError, SyntaxError => err
496 error report_error("#{desc}#{fname} load failed", err)
497 bt = err.backtrace.select { |line|
498 line.match(/^(\(eval\)|#{fname}):\d+/)
501 el.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
505 msg = err.to_str.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
508 newerr = err.class.new(msg)
509 newerr.set_backtrace(bt)
513 private :load_botmodule_file
515 # add one or more directories to the list of directories to
516 # load botmodules from
518 # TODO find a way to specify necessary plugins which _must_ be loaded
520 def add_botmodule_dir(*dirlist)
522 debug "Botmodule loading path: #{@dirs.join(', ')}"
525 def clear_botmodule_dirs
527 debug "Botmodule loading path cleared"
530 # load plugins from pre-assigned list of directories
538 @bot.config['plugins.blacklist'].each { |p|
540 processed[pn.intern] = :blacklisted
545 if(FileTest.directory?(dir))
549 next if(file =~ /^\./)
551 if processed.has_key?(file.intern)
552 @ignored << {:name => file, :dir => dir, :reason => processed[file.intern]}
556 if(file =~ /^(.+\.rb)\.disabled$/)
557 # GB: Do we want to do this? This means that a disabled plugin in a directory
558 # will disable in all subsequent directories. This was probably meant
559 # to be used before plugins.blacklist was implemented, so I think
560 # we don't need this anymore
561 processed[$1.intern] = :disabled
562 @ignored << {:name => $1, :dir => dir, :reason => processed[$1.intern]}
566 next unless(file =~ /\.rb$/)
568 did_it = load_botmodule_file("#{dir}/#{file}", "plugin")
571 processed[file.intern] = did_it
573 @failed << { :name => file, :dir => dir, :reason => did_it }
579 debug "finished loading plugins: #{status(true)}"
580 (core_modules + plugins).each { |p|
581 p.methods.grep(DEFAULT_DELEGATE_PATTERNS).each { |m|
582 @delegate_list[m.intern] << p
587 # call the save method for each active plugin
589 delegate 'flush_registry'
593 # call the cleanup method for each active plugin
596 reset_botmodule_lists
599 # drop all plugins and rescan plugins on disk
600 # calls save and cleanup for each plugin before dropping them
607 def status(short=false)
609 if self.core_length > 0
611 output << n_("%{count} core module loaded", "%{count} core modules loaded",
612 self.core_length) % {:count => self.core_length}
614 output << n_("%{count} core module: %{list}",
615 "%{count} core modules: %{list}", self.core_length) %
616 { :count => self.core_length,
617 :list => core_modules.collect{ |p| p.name}.sort.join(", ") }
620 output << _("no core botmodules loaded")
622 # Active plugins first
625 output << n_("%{count} plugin loaded", "%{count} plugins loaded",
626 self.length) % {:count => self.length}
628 output << n_("%{count} plugin: %{list}",
629 "%{count} plugins: %{list}", self.length) %
630 { :count => self.length,
631 :list => plugins.collect{ |p| p.name}.sort.join(", ") }
634 output << "no plugins active"
636 # Ignored plugins next
637 unless @ignored.empty? or @failures_shown
639 output << n_("%{highlight}%{count} plugin ignored%{highlight}",
640 "%{highlight}%{count} plugins ignored%{highlight}",
642 { :count => @ignored.length, :highlight => Underline }
644 output << n_("%{highlight}%{count} plugin ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
645 "%{highlight}%{count} plugins ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
647 { :count => @ignored.length, :highlight => Underline,
648 :bold => Bold, :command => "help ignored plugins"}
651 # Failed plugins next
652 unless @failed.empty? or @failures_shown
654 output << n_("%{highlight}%{count} plugin failed to load%{highlight}",
655 "%{highlight}%{count} plugins failed to load%{highlight}",
657 { :count => @failed.length, :highlight => Reverse }
659 output << n_("%{highlight}%{count} plugin failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
660 "%{highlight}%{count} plugins failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
662 { :count => @failed.length, :highlight => Reverse,
663 :bold => Bold, :command => "help failed plugins"}
669 # return list of help topics (plugin names)
672 @failures_shown = true
684 # return help for +topic+ (call associated plugin's help method)
687 when /fail(?:ed)?\s*plugins?.*(trace(?:back)?s?)?/
688 # debug "Failures: #{@failed.inspect}"
689 return _("no plugins failed to load") if @failed.empty?
690 return @failed.collect { |p|
691 _('%{highlight}%{plugin}%{highlight} in %{dir} failed with error %{exception}: %{reason}') % {
692 :highlight => Bold, :plugin => p[:name], :dir => p[:dir],
693 :exception => p[:reason].class, :reason => p[:reason],
694 } + if $1 && !p[:reason].backtrace.empty?
695 _('at %{backtrace}') % {:backtrace => p[:reason].backtrace.join(', ')}
700 when /ignored?\s*plugins?/
701 return _('no plugins were ignored') if @ignored.empty?
705 reason = p[:loaded] ? _('overruled by previous') : _(p[:reason].to_s)
706 ((tmp[p[:dir]] ||= Hash.new)[reason] ||= Array.new).push(p[:name])
709 return tmp.map do |dir, reasons|
710 # FIXME get rid of these string concatenations to make gettext easier
711 s = reasons.map { |r, list|
712 list.map { |_| _.sub(/\.rb$/, '') }.join(', ') + " (#{r})"
716 when /^(\S+)\s*(.*)$/
720 # Let's see if we can match a plugin by the given name
721 (core_modules + plugins).each { |p|
722 next unless p.name == key
724 return p.help(key, params)
725 rescue Exception => err
726 #rescue TimeoutError, StandardError, NameError, SyntaxError => err
727 error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
731 # Nope, let's see if it's a command, and ask for help at the corresponding botmodule
733 if commands.has_key?(k)
734 p = commands[k][:botmodule]
736 return p.help(key, params)
737 rescue Exception => err
738 #rescue TimeoutError, StandardError, NameError, SyntaxError => err
739 error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
746 # see if each plugin handles +method+, and if so, call it, passing
747 # +message+ as a parameter
748 def delegate(method, *args)
749 # debug "Delegating #{method.inspect}"
751 if method.match(DEFAULT_DELEGATE_PATTERNS)
752 debug "fast-delegating #{method}"
754 debug "no-one to delegate to" unless @delegate_list.has_key?(m)
755 return [] unless @delegate_list.has_key?(m)
756 @delegate_list[m].each { |p|
758 ret.push p.send(method, *args)
759 rescue Exception => err
760 raise if err.kind_of?(SystemExit)
761 error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
762 raise if err.kind_of?(BDB::Fatal)
766 debug "slow-delegating #{method}"
767 (core_modules + plugins).each { |p|
768 if(p.respond_to? method)
770 # debug "#{p.botmodule_class} #{p.name} responds"
771 ret.push p.send(method, *args)
772 rescue Exception => err
773 raise if err.kind_of?(SystemExit)
774 error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
775 raise if err.kind_of?(BDB::Fatal)
781 # debug "Finished delegating #{method.inspect}"
784 # see if we have a plugin that wants to handle this message, if so, pass
785 # it to the plugin and return true, otherwise false
787 # debug "Delegating privmsg #{m.message.inspect} from #{m.source} to #{m.replyto} with pluginkey #{m.plugin.inspect}"
788 return unless m.plugin
790 if commands.has_key?(k)
791 p = commands[k][:botmodule]
792 a = commands[k][:auth]
793 # We check here for things that don't check themselves
794 # (e.g. mapped things)
795 # debug "Checking auth ..."
796 if a.nil? || @bot.auth.allow?(a, m.source, m.replyto)
797 # debug "Checking response ..."
798 if p.respond_to?("privmsg")
800 # debug "#{p.botmodule_class} #{p.name} responds"
802 rescue Exception => err
803 raise if err.kind_of?(SystemExit)
804 error report_error("#{p.botmodule_class} #{p.name} privmsg() failed:", err)
805 raise if err.kind_of?(BDB::Fatal)
807 # debug "Successfully delegated #{m.message}"
810 # debug "#{p.botmodule_class} #{p.name} is registered, but it doesn't respond to privmsg()"
813 # debug "#{p.botmodule_class} #{p.name} is registered, but #{m.source} isn't allowed to call #{m.plugin.inspect} on #{m.replyto}"
816 # debug "Finished delegating privmsg with key #{m.plugin.inspect}" + ( pl.empty? ? "" : " to #{pl.values.first[:botmodule].botmodule_class}s" )
818 # debug "Finished delegating privmsg with key #{m.plugin.inspect}"
822 # Returns the only PluginManagerClass instance
824 return PluginManagerClass.instance