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.
99 Called when a user (or the bot) joins a channel
102 Called when a user (or the bot) parts a channel
105 Called when a user (or the bot) quits IRC
108 Called when a user (or the bot) changes Nick
109 topic(TopicMessage)::
110 Called when a user (or the bot) changes a channel
113 connect:: Called when a server is joined successfully, but
114 before autojoin channels are joined (no params)
116 set_language(String)::
117 Called when the user sets a new language
118 whose name is the given String
120 save:: Called when you are required to save your plugin's
121 state, if you maintain data between sessions
123 cleanup:: called before your plugin is "unloaded", prior to a
124 plugin reload or bot quit - close any open
125 files/connections or flush caches here
129 attr_reader :bot # the associated bot
131 # Initialise your bot module. Always call super if you override this method,
132 # as important variables are set up for you:
137 # the botmodule's registry, which can be used to store permanent data
138 # (see Registry::Accessor for additional documentation)
140 # Other instance variables which are defined and should not be overwritten
141 # byt the user, but aren't usually accessed directly, are:
144 # the plugins manager instance
145 # @botmodule_triggers::
146 # an Array of words this plugin #register()ed itself for
148 # the MessageMapper that handles this plugin's maps
151 @manager = Plugins::manager
154 @botmodule_triggers = Array.new
156 @handler = MessageMapper.new(self)
157 @registry = Registry::Accessor.new(@bot, self.class.to_s.gsub(/^.*::/, ""))
159 @manager.add_botmodule(self)
160 if self.respond_to?('set_language')
161 self.set_language(@bot.lang.language)
165 # Returns the symbol :BotModule
170 # Method called to flush the registry, thus ensuring that the botmodule's permanent
171 # data is committed to disk
174 # debug "Flushing #{@registry}"
178 # Method called to cleanup before the plugin is unloaded. If you overload
179 # this method to handle additional cleanup tasks, remember to call super()
180 # so that the default cleanup actions are taken care of as well.
183 # debug "Closing #{@registry}"
187 # Handle an Irc::PrivMessage for which this BotModule has a map. The method
188 # is called automatically and there is usually no need to call it
195 # Signal to other BotModules that an even happened.
197 def call_event(ev, *args)
198 @bot.plugins.delegate('event_' + ev.to_s.gsub(/[^\w\?!]+/, '_'), *args)
201 # call-seq: map(template, options)
203 # This is the preferred way to register the BotModule so that it
204 # responds to appropriately-formed messages on Irc.
210 # call-seq: map!(template, options)
212 # This is the same as map but doesn't register the new command
213 # as an alternative name for the plugin.
219 # Auxiliary method called by #map and #map!
220 def do_map(silent, *args)
221 @handler.map(self, *args)
225 self.register name, :auth => nil, :hidden => silent
226 @manager.register_map(self, map)
227 unless self.respond_to?('privmsg')
228 def self.privmsg(m) #:nodoc:
234 # Sets the default auth for command path _cmd_ to _val_ on channel _chan_:
235 # usually _chan_ is either "*" for everywhere, public and private (in which
236 # case it can be omitted) or "?" for private communications
238 def default_auth(cmd, val, chan="*")
245 Auth::defaultbotuser.set_default_permission(propose_default_path(c), val)
248 # Gets the default command path which would be given to command _cmd_
249 def propose_default_path(cmd)
250 [name, cmd].compact.join("::")
253 # Return an identifier for this plugin, defaults to a list of the message
254 # prefixes handled (used for error messages etc)
256 self.class.to_s.downcase.sub(/^#<module:.*?>::/,"").sub(/(plugin|module)?$/,"")
269 # Return a help string for your module. For complex modules, you may wish
270 # to break your help into topics, and return a list of available topics if
271 # +topic+ is nil. +plugin+ is passed containing the matching prefix for
272 # this message - if your plugin handles multiple prefixes, make sure you
273 # return the correct help for the prefix requested
274 def help(plugin, topic)
278 # Register the plugin as a handler for messages prefixed _cmd_.
280 # This can be called multiple times for a plugin to handle multiple message
283 # This command is now superceded by the #map() command, which should be used
284 # instead whenever possible.
286 def register(cmd, opts={})
287 raise ArgumentError, "Second argument must be a hash!" unless opts.kind_of?(Hash)
288 who = @manager.who_handles?(cmd)
290 raise "Command #{cmd} is already handled by #{who.botmodule_class} #{who}" if who != self
293 if opts.has_key?(:auth)
294 @manager.register(self, cmd, opts[:auth])
296 @manager.register(self, cmd, propose_default_path(cmd))
298 @botmodule_triggers << cmd unless opts.fetch(:hidden, false)
301 # Default usage method provided as a utility for simple plugins. The
302 # MessageMapper uses 'usage' as its default fallback method.
304 def usage(m, params = {})
305 m.reply(_("incorrect usage, ask for help using '%{command}'") % {:command => "#{@bot.nick}: help #{m.plugin}"})
310 # A CoreBotModule is a BotModule that provides core functionality.
312 # This class should not be used by user plugins, as it's reserved for system
313 # plugins such as the ones that handle authentication, configuration and basic
316 class CoreBotModule < BotModule
322 # A Plugin is a BotModule that provides additional functionality.
324 # A user-defined plugin should subclass this, and then define any of the
325 # methods described in the documentation for BotModule to handle interaction
328 class Plugin < BotModule
334 # Singleton to manage multiple plugins and delegate messages to them for
336 class PluginManagerClass
339 attr_reader :botmodules
342 # This is the list of patterns commonly delegated to plugins.
343 # A fast delegation lookup is enabled for them.
344 DEFAULT_DELEGATE_PATTERNS = %r{^(?:
346 listen|ctcp_listen|privmsg|unreplied|
348 save|cleanup|flush_registry|
354 :CoreBotModule => [],
358 @names_hash = Hash.new
359 @commandmappers = Hash.new
361 @delegate_list = Hash.new { |h, k|
374 ret = self.to_s[0..-2]
375 ret << ' corebotmodules='
376 ret << @botmodules[:CoreBotModule].map { |m|
380 ret << @botmodules[:Plugin].map { |m|
386 # Reset lists of botmodules
387 def reset_botmodule_lists
388 @botmodules[:CoreBotModule].clear
389 @botmodules[:Plugin].clear
391 @commandmappers.clear
393 @failures_shown = false
396 # Associate with bot _bot_
397 def bot_associate(bot)
398 reset_botmodule_lists
402 # Returns the botmodule with the given _name_
404 @names_hash[name.to_sym]
407 # Returns +true+ if _cmd_ has already been registered as a command
408 def who_handles?(cmd)
409 return nil unless @commandmappers.has_key?(cmd.to_sym)
410 return @commandmappers[cmd.to_sym][:botmodule]
413 # Registers botmodule _botmodule_ with command _cmd_ and command path _auth_path_
414 def register(botmodule, cmd, auth_path)
415 raise TypeError, "First argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
416 @commandmappers[cmd.to_sym] = {:botmodule => botmodule, :auth => auth_path}
419 # Registers botmodule _botmodule_ with map _map_. This adds the map to the #maps hash
420 # which has three keys:
422 # botmodule:: the associated botmodule
423 # auth:: an array of auth keys checked by the map; the first is the full_auth_path of the map
424 # map:: the actual MessageTemplate object
427 def register_map(botmodule, map)
428 raise TypeError, "First argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
429 @maps[map.template] = { :botmodule => botmodule, :auth => [map.options[:full_auth_path]], :map => map }
432 def add_botmodule(botmodule)
433 raise TypeError, "Argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
434 kl = botmodule.botmodule_class
435 if @names_hash.has_key?(botmodule.to_sym)
436 case self[botmodule].botmodule_class
438 raise "#{kl} #{botmodule} already registered!"
440 raise "#{self[botmodule].botmodule_class} #{botmodule} already registered, cannot re-register as #{kl}"
443 @botmodules[kl] << botmodule
444 @names_hash[botmodule.to_sym] = botmodule
447 # Returns an array of the loaded plugins
449 @botmodules[:CoreBotModule]
452 # Returns an array of the loaded plugins
457 # Returns a hash of the registered message prefixes and associated
463 # Makes a string of error _err_ by adding text _str_
464 def report_error(str, err)
465 ([str, err.inspect] + err.backtrace).join("\n")
468 # This method is the one that actually loads a module from the
471 # _desc_ is a simple description of what we are loading (plugin/botmodule/whatever)
473 # It returns the Symbol :loaded on success, and an Exception
476 def load_botmodule_file(fname, desc=nil)
477 # create a new, anonymous module to "house" the plugin
478 # the idea here is to prevent namespace pollution. perhaps there
480 plugin_module = Module.new
482 desc = desc.to_s + " " if desc
485 plugin_string = IO.readlines(fname).join("")
486 debug "loading #{desc}#{fname}"
487 plugin_module.module_eval(plugin_string, fname)
489 rescue Exception => err
490 # rescue TimeoutError, StandardError, NameError, LoadError, SyntaxError => err
491 error report_error("#{desc}#{fname} load failed", err)
492 bt = err.backtrace.select { |line|
493 line.match(/^(\(eval\)|#{fname}):\d+/)
496 el.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
500 msg = err.to_str.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
503 newerr = err.class.new(msg)
504 newerr.set_backtrace(bt)
508 private :load_botmodule_file
510 # add one or more directories to the list of directories to
511 # load botmodules from
513 # TODO find a way to specify necessary plugins which _must_ be loaded
515 def add_botmodule_dir(*dirlist)
517 debug "Botmodule loading path: #{@dirs.join(', ')}"
520 def clear_botmodule_dirs
522 debug "Botmodule loading path cleared"
525 # load plugins from pre-assigned list of directories
533 @bot.config['plugins.blacklist'].each { |p|
535 processed[pn.intern] = :blacklisted
540 if(FileTest.directory?(dir))
544 next if(file =~ /^\./)
546 if processed.has_key?(file.intern)
547 @ignored << {:name => file, :dir => dir, :reason => processed[file.intern]}
551 if(file =~ /^(.+\.rb)\.disabled$/)
552 # GB: Do we want to do this? This means that a disabled plugin in a directory
553 # will disable in all subsequent directories. This was probably meant
554 # to be used before plugins.blacklist was implemented, so I think
555 # we don't need this anymore
556 processed[$1.intern] = :disabled
557 @ignored << {:name => $1, :dir => dir, :reason => processed[$1.intern]}
561 next unless(file =~ /\.rb$/)
563 did_it = load_botmodule_file("#{dir}/#{file}", "plugin")
566 processed[file.intern] = did_it
568 @failed << { :name => file, :dir => dir, :reason => did_it }
574 debug "finished loading plugins: #{status(true)}"
575 (core_modules + plugins).each { |p|
576 p.methods.grep(DEFAULT_DELEGATE_PATTERNS).each { |m|
577 @delegate_list[m.intern] << p
582 # call the save method for each active plugin
584 delegate 'flush_registry'
588 # call the cleanup method for each active plugin
591 reset_botmodule_lists
594 # drop all plugins and rescan plugins on disk
595 # calls save and cleanup for each plugin before dropping them
602 def status(short=false)
604 if self.core_length > 0
606 output << n_("%{count} core module loaded", "%{count} core modules loaded",
607 self.core_length) % {:count => self.core_length}
609 output << n_("%{count} core module: %{list}",
610 "%{count} core modules: %{list}", self.core_length) %
611 { :count => self.core_length,
612 :list => core_modules.collect{ |p| p.name}.sort.join(", ") }
615 output << _("no core botmodules loaded")
617 # Active plugins first
620 output << n_("%{count} plugin loaded", "%{count} plugins loaded",
621 self.length) % {:count => self.length}
623 output << n_("%{count} plugin: %{list}",
624 "%{count} plugins: %{list}", self.length) %
625 { :count => self.length,
626 :list => plugins.collect{ |p| p.name}.sort.join(", ") }
629 output << "no plugins active"
631 # Ignored plugins next
632 unless @ignored.empty? or @failures_shown
634 output << n_("%{highlight}%{count} plugin ignored%{highlight}",
635 "%{highlight}%{count} plugins ignored%{highlight}",
637 { :count => @ignored.length, :highlight => Underline }
639 output << n_("%{highlight}%{count} plugin ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
640 "%{highlight}%{count} plugins ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
642 { :count => @ignored.length, :highlight => Underline,
643 :bold => Bold, :command => "help ignored plugins"}
646 # Failed plugins next
647 unless @failed.empty? or @failures_shown
649 output << n_("%{highlight}%{count} plugin failed to load%{highlight}",
650 "%{highlight}%{count} plugins failed to load%{highlight}",
652 { :count => @failed.length, :highlight => Reverse }
654 output << n_("%{highlight}%{count} plugin failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
655 "%{highlight}%{count} plugins failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
657 { :count => @failed.length, :highlight => Reverse,
658 :bold => Bold, :command => "help failed plugins"}
664 # return list of help topics (plugin names)
667 @failures_shown = true
679 # return help for +topic+ (call associated plugin's help method)
682 when /fail(?:ed)?\s*plugins?.*(trace(?:back)?s?)?/
683 # debug "Failures: #{@failed.inspect}"
684 return _("no plugins failed to load") if @failed.empty?
685 return @failed.collect { |p|
686 _('%{highlight}%{plugin}%{highlight} in %{dir} failed with error %{exception}: %{reason}') % {
687 :highlight => Bold, :plugin => p[:name], :dir => p[:dir],
688 :exception => p[:reason].class, :reason => p[:reason],
689 } + if $1 && !p[:reason].backtrace.empty?
690 _('at %{backtrace}') % {:backtrace => p[:reason].backtrace.join(', ')}
695 when /ignored?\s*plugins?/
696 return _('no plugins were ignored') if @ignored.empty?
700 reason = p[:loaded] ? _('overruled by previous') : _(p[:reason].to_s)
701 ((tmp[p[:dir]] ||= Hash.new)[reason] ||= Array.new).push(p[:name])
704 return tmp.map do |dir, reasons|
705 # FIXME get rid of these string concatenations to make gettext easier
706 s = reasons.map { |r, list|
707 list.map { |_| _.sub(/\.rb$/, '') }.join(', ') + " (#{r})"
711 when /^(\S+)\s*(.*)$/
715 # Let's see if we can match a plugin by the given name
716 (core_modules + plugins).each { |p|
717 next unless p.name == key
719 return p.help(key, params)
720 rescue Exception => err
721 #rescue TimeoutError, StandardError, NameError, SyntaxError => err
722 error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
726 # Nope, let's see if it's a command, and ask for help at the corresponding botmodule
728 if commands.has_key?(k)
729 p = commands[k][:botmodule]
731 return p.help(key, params)
732 rescue Exception => err
733 #rescue TimeoutError, StandardError, NameError, SyntaxError => err
734 error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
741 # see if each plugin handles +method+, and if so, call it, passing
742 # +message+ as a parameter
743 def delegate(method, *args)
744 # debug "Delegating #{method.inspect}"
746 if method.match(DEFAULT_DELEGATE_PATTERNS)
747 debug "fast-delegating #{method}"
749 debug "no-one to delegate to" unless @delegate_list.has_key?(m)
750 return [] unless @delegate_list.has_key?(m)
751 @delegate_list[m].each { |p|
753 ret.push p.send(method, *args)
754 rescue Exception => err
755 raise if err.kind_of?(SystemExit)
756 error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
757 raise if err.kind_of?(BDB::Fatal)
761 debug "slow-delegating #{method}"
762 (core_modules + plugins).each { |p|
763 if(p.respond_to? method)
765 # debug "#{p.botmodule_class} #{p.name} responds"
766 ret.push p.send(method, *args)
767 rescue Exception => err
768 raise if err.kind_of?(SystemExit)
769 error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
770 raise if err.kind_of?(BDB::Fatal)
776 # debug "Finished delegating #{method.inspect}"
779 # see if we have a plugin that wants to handle this message, if so, pass
780 # it to the plugin and return true, otherwise false
782 # debug "Delegating privmsg #{m.message.inspect} from #{m.source} to #{m.replyto} with pluginkey #{m.plugin.inspect}"
783 return unless m.plugin
785 if commands.has_key?(k)
786 p = commands[k][:botmodule]
787 a = commands[k][:auth]
788 # We check here for things that don't check themselves
789 # (e.g. mapped things)
790 # debug "Checking auth ..."
791 if a.nil? || @bot.auth.allow?(a, m.source, m.replyto)
792 # debug "Checking response ..."
793 if p.respond_to?("privmsg")
795 # debug "#{p.botmodule_class} #{p.name} responds"
797 rescue Exception => err
798 raise if err.kind_of?(SystemExit)
799 error report_error("#{p.botmodule_class} #{p.name} privmsg() failed:", err)
800 raise if err.kind_of?(BDB::Fatal)
802 # debug "Successfully delegated #{m.message}"
805 # debug "#{p.botmodule_class} #{p.name} is registered, but it doesn't respond to privmsg()"
808 # debug "#{p.botmodule_class} #{p.name} is registered, but #{m.source} isn't allowed to call #{m.plugin.inspect} on #{m.replyto}"
811 # debug "Finished delegating privmsg with key #{m.plugin.inspect}" + ( pl.empty? ? "" : " to #{pl.values.first[:botmodule].botmodule_class}s" )
813 # debug "Finished delegating privmsg with key #{m.plugin.inspect}"
817 # Returns the only PluginManagerClass instance
819 return PluginManagerClass.instance