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.
99 notice(NoticeMessage)::
100 Called for all Notices. Please notice that in general
101 should not be replied to.
104 Called when a user (or the bot) is kicked from a
105 channel the bot is in.
107 invite(InviteMessage)::
108 Called when the bot is invited to a channel.
111 Called when a user (or the bot) joins a channel
114 Called when a user (or the bot) parts a channel
117 Called when a user (or the bot) quits IRC
120 Called when a user (or the bot) changes Nick
121 topic(TopicMessage)::
122 Called when a user (or the bot) changes a channel
125 welcome(WelcomeMessage)::
126 Called when the welcome message is received on
127 joining a server succesfully.
129 connect:: Called when a server is joined successfully, but
130 before autojoin channels are joined (no params)
132 set_language(String)::
133 Called when the user sets a new language
134 whose name is the given String
136 save:: Called when you are required to save your plugin's
137 state, if you maintain data between sessions
139 cleanup:: called before your plugin is "unloaded", prior to a
140 plugin reload or bot quit - close any open
141 files/connections or flush caches here
148 # the plugin registry
149 attr_reader :registry
151 # the message map handler
154 # Initialise your bot module. Always call super if you override this method,
155 # as important variables are set up for you:
160 # the botmodule's registry, which can be used to store permanent data
161 # (see Registry::Accessor for additional documentation)
163 # Other instance variables which are defined and should not be overwritten
164 # byt the user, but aren't usually accessed directly, are:
167 # the plugins manager instance
168 # @botmodule_triggers::
169 # an Array of words this plugin #register()ed itself for
171 # the MessageMapper that handles this plugin's maps
174 @manager = Plugins::manager
177 @botmodule_triggers = Array.new
179 @handler = MessageMapper.new(self)
180 @registry = Registry::Accessor.new(@bot, self.class.to_s.gsub(/^.*::/, ""))
182 @manager.add_botmodule(self)
183 if self.respond_to?('set_language')
184 self.set_language(@bot.lang.language)
188 # Changing the value of @priority directly will cause problems,
189 # Please use priority=.
194 # Returns the symbol :BotModule
199 # Method called to flush the registry, thus ensuring that the botmodule's permanent
200 # data is committed to disk
203 # debug "Flushing #{@registry}"
207 # Method called to cleanup before the plugin is unloaded. If you overload
208 # this method to handle additional cleanup tasks, remember to call super()
209 # so that the default cleanup actions are taken care of as well.
212 # debug "Closing #{@registry}"
216 # Handle an Irc::PrivMessage for which this BotModule has a map. The method
217 # is called automatically and there is usually no need to call it
224 # Signal to other BotModules that an even happened.
226 def call_event(ev, *args)
227 @bot.plugins.delegate('event_' + ev.to_s.gsub(/[^\w\?!]+/, '_'), *args)
230 # call-seq: map(template, options)
232 # This is the preferred way to register the BotModule so that it
233 # responds to appropriately-formed messages on Irc.
239 # call-seq: map!(template, options)
241 # This is the same as map but doesn't register the new command
242 # as an alternative name for the plugin.
248 # Auxiliary method called by #map and #map!
249 def do_map(silent, *args)
250 @handler.map(self, *args)
254 self.register name, :auth => nil, :hidden => silent
255 @manager.register_map(self, map)
256 unless self.respond_to?('privmsg')
257 def self.privmsg(m) #:nodoc:
263 # Sets the default auth for command path _cmd_ to _val_ on channel _chan_:
264 # usually _chan_ is either "*" for everywhere, public and private (in which
265 # case it can be omitted) or "?" for private communications
267 def default_auth(cmd, val, chan="*")
274 Auth::defaultbotuser.set_default_permission(propose_default_path(c), val)
277 # Gets the default command path which would be given to command _cmd_
278 def propose_default_path(cmd)
279 [name, cmd].compact.join("::")
282 # Return an identifier for this plugin, defaults to a list of the message
283 # prefixes handled (used for error messages etc)
285 self.class.to_s.downcase.sub(/^#<module:.*?>::/,"").sub(/(plugin|module)?$/,"")
298 # Return a help string for your module. For complex modules, you may wish
299 # to break your help into topics, and return a list of available topics if
300 # +topic+ is nil. +plugin+ is passed containing the matching prefix for
301 # this message - if your plugin handles multiple prefixes, make sure you
302 # return the correct help for the prefix requested
303 def help(plugin, topic)
307 # Register the plugin as a handler for messages prefixed _cmd_.
309 # This can be called multiple times for a plugin to handle multiple message
312 # This command is now superceded by the #map() command, which should be used
313 # instead whenever possible.
315 def register(cmd, opts={})
316 raise ArgumentError, "Second argument must be a hash!" unless opts.kind_of?(Hash)
317 who = @manager.who_handles?(cmd)
319 raise "Command #{cmd} is already handled by #{who.botmodule_class} #{who}" if who != self
322 if opts.has_key?(:auth)
323 @manager.register(self, cmd, opts[:auth])
325 @manager.register(self, cmd, propose_default_path(cmd))
327 @botmodule_triggers << cmd unless opts.fetch(:hidden, false)
330 # Default usage method provided as a utility for simple plugins. The
331 # MessageMapper uses 'usage' as its default fallback method.
333 def usage(m, params = {})
334 m.reply(_("incorrect usage, ask for help using '%{command}'") % {:command => "#{@bot.nick}: help #{m.plugin}"})
337 # Define the priority of the module. During event delegation, lower
338 # priority modules will be called first. Default priority is 1
342 @bot.plugins.mark_priorities_dirty
347 # A CoreBotModule is a BotModule that provides core functionality.
349 # This class should not be used by user plugins, as it's reserved for system
350 # plugins such as the ones that handle authentication, configuration and basic
353 class CoreBotModule < BotModule
359 # A Plugin is a BotModule that provides additional functionality.
361 # A user-defined plugin should subclass this, and then define any of the
362 # methods described in the documentation for BotModule to handle interaction
365 class Plugin < BotModule
371 # Singleton to manage multiple plugins and delegate messages to them for
373 class PluginManagerClass
376 attr_reader :botmodules
379 # This is the list of patterns commonly delegated to plugins.
380 # A fast delegation lookup is enabled for them.
381 DEFAULT_DELEGATE_PATTERNS = %r{^(?:
383 listen|ctcp_listen|privmsg|unreplied|
385 save|cleanup|flush_registry|
391 :CoreBotModule => [],
395 @names_hash = Hash.new
396 @commandmappers = Hash.new
399 # modules will be sorted on first delegate call
400 @sorted_modules = nil
402 @delegate_list = Hash.new { |h, k|
415 ret = self.to_s[0..-2]
416 ret << ' corebotmodules='
417 ret << @botmodules[:CoreBotModule].map { |m|
421 ret << @botmodules[:Plugin].map { |m|
427 # Reset lists of botmodules
428 def reset_botmodule_lists
429 @botmodules[:CoreBotModule].clear
430 @botmodules[:Plugin].clear
432 @commandmappers.clear
434 @failures_shown = false
435 mark_priorities_dirty
438 # Associate with bot _bot_
439 def bot_associate(bot)
440 reset_botmodule_lists
444 # Returns the botmodule with the given _name_
446 @names_hash[name.to_sym]
449 # Returns +true+ if _cmd_ has already been registered as a command
450 def who_handles?(cmd)
451 return nil unless @commandmappers.has_key?(cmd.to_sym)
452 return @commandmappers[cmd.to_sym][:botmodule]
455 # Registers botmodule _botmodule_ with command _cmd_ and command path _auth_path_
456 def register(botmodule, cmd, auth_path)
457 raise TypeError, "First argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
458 @commandmappers[cmd.to_sym] = {:botmodule => botmodule, :auth => auth_path}
461 # Registers botmodule _botmodule_ with map _map_. This adds the map to the #maps hash
462 # which has three keys:
464 # botmodule:: the associated botmodule
465 # auth:: an array of auth keys checked by the map; the first is the full_auth_path of the map
466 # map:: the actual MessageTemplate object
469 def register_map(botmodule, map)
470 raise TypeError, "First argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
471 @maps[map.template] = { :botmodule => botmodule, :auth => [map.options[:full_auth_path]], :map => map }
474 def add_botmodule(botmodule)
475 raise TypeError, "Argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
476 kl = botmodule.botmodule_class
477 if @names_hash.has_key?(botmodule.to_sym)
478 case self[botmodule].botmodule_class
480 raise "#{kl} #{botmodule} already registered!"
482 raise "#{self[botmodule].botmodule_class} #{botmodule} already registered, cannot re-register as #{kl}"
485 @botmodules[kl] << botmodule
486 @names_hash[botmodule.to_sym] = botmodule
487 mark_priorities_dirty
490 # Returns an array of the loaded plugins
492 @botmodules[:CoreBotModule]
495 # Returns an array of the loaded plugins
500 # Returns a hash of the registered message prefixes and associated
506 # Tells the PluginManager that the next time it delegates an event, it
507 # should sort the modules by priority
508 def mark_priorities_dirty
509 @sorted_modules = nil
512 # Makes a string of error _err_ by adding text _str_
513 def report_error(str, err)
514 ([str, err.inspect] + err.backtrace).join("\n")
517 # This method is the one that actually loads a module from the
520 # _desc_ is a simple description of what we are loading (plugin/botmodule/whatever)
522 # It returns the Symbol :loaded on success, and an Exception
525 def load_botmodule_file(fname, desc=nil)
526 # create a new, anonymous module to "house" the plugin
527 # the idea here is to prevent namespace pollution. perhaps there
529 plugin_module = Module.new
531 desc = desc.to_s + " " if desc
534 plugin_string = IO.readlines(fname).join("")
535 debug "loading #{desc}#{fname}"
536 plugin_module.module_eval(plugin_string, fname)
538 rescue Exception => err
539 # rescue TimeoutError, StandardError, NameError, LoadError, SyntaxError => err
540 error report_error("#{desc}#{fname} load failed", err)
541 bt = err.backtrace.select { |line|
542 line.match(/^(\(eval\)|#{fname}):\d+/)
545 el.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
549 msg = err.to_str.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
552 newerr = err.class.new(msg)
553 newerr.set_backtrace(bt)
557 private :load_botmodule_file
559 # add one or more directories to the list of directories to
560 # load botmodules from
562 # TODO find a way to specify necessary plugins which _must_ be loaded
564 def add_botmodule_dir(*dirlist)
566 debug "Botmodule loading path: #{@dirs.join(', ')}"
569 def clear_botmodule_dirs
571 debug "Botmodule loading path cleared"
574 # load plugins from pre-assigned list of directories
582 @bot.config['plugins.blacklist'].each { |p|
584 processed[pn.intern] = :blacklisted
589 if(FileTest.directory?(dir))
593 next if(file =~ /^\./)
595 if processed.has_key?(file.intern)
596 @ignored << {:name => file, :dir => dir, :reason => processed[file.intern]}
600 if(file =~ /^(.+\.rb)\.disabled$/)
601 # GB: Do we want to do this? This means that a disabled plugin in a directory
602 # will disable in all subsequent directories. This was probably meant
603 # to be used before plugins.blacklist was implemented, so I think
604 # we don't need this anymore
605 processed[$1.intern] = :disabled
606 @ignored << {:name => $1, :dir => dir, :reason => processed[$1.intern]}
610 next unless(file =~ /\.rb$/)
612 did_it = load_botmodule_file("#{dir}/#{file}", "plugin")
615 processed[file.intern] = did_it
617 @failed << { :name => file, :dir => dir, :reason => did_it }
623 debug "finished loading plugins: #{status(true)}"
624 (core_modules + plugins).each { |p|
625 p.methods.grep(DEFAULT_DELEGATE_PATTERNS).each { |m|
626 @delegate_list[m.intern] << p
629 mark_priorities_dirty
632 # call the save method for each active plugin
634 delegate 'flush_registry'
638 # call the cleanup method for each active plugin
641 reset_botmodule_lists
644 # drop all plugins and rescan plugins on disk
645 # calls save and cleanup for each plugin before dropping them
652 def status(short=false)
654 if self.core_length > 0
656 output << n_("%{count} core module loaded", "%{count} core modules loaded",
657 self.core_length) % {:count => self.core_length}
659 output << n_("%{count} core module: %{list}",
660 "%{count} core modules: %{list}", self.core_length) %
661 { :count => self.core_length,
662 :list => core_modules.collect{ |p| p.name}.sort.join(", ") }
665 output << _("no core botmodules loaded")
667 # Active plugins first
670 output << n_("%{count} plugin loaded", "%{count} plugins loaded",
671 self.length) % {:count => self.length}
673 output << n_("%{count} plugin: %{list}",
674 "%{count} plugins: %{list}", self.length) %
675 { :count => self.length,
676 :list => plugins.collect{ |p| p.name}.sort.join(", ") }
679 output << "no plugins active"
681 # Ignored plugins next
682 unless @ignored.empty? or @failures_shown
684 output << n_("%{highlight}%{count} plugin ignored%{highlight}",
685 "%{highlight}%{count} plugins ignored%{highlight}",
687 { :count => @ignored.length, :highlight => Underline }
689 output << n_("%{highlight}%{count} plugin ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
690 "%{highlight}%{count} plugins ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
692 { :count => @ignored.length, :highlight => Underline,
693 :bold => Bold, :command => "help ignored plugins"}
696 # Failed plugins next
697 unless @failed.empty? or @failures_shown
699 output << n_("%{highlight}%{count} plugin failed to load%{highlight}",
700 "%{highlight}%{count} plugins failed to load%{highlight}",
702 { :count => @failed.length, :highlight => Reverse }
704 output << n_("%{highlight}%{count} plugin failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
705 "%{highlight}%{count} plugins failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
707 { :count => @failed.length, :highlight => Reverse,
708 :bold => Bold, :command => "help failed plugins"}
714 # return list of help topics (plugin names)
717 @failures_shown = true
729 # return help for +topic+ (call associated plugin's help method)
732 when /fail(?:ed)?\s*plugins?.*(trace(?:back)?s?)?/
733 # debug "Failures: #{@failed.inspect}"
734 return _("no plugins failed to load") if @failed.empty?
735 return @failed.collect { |p|
736 _('%{highlight}%{plugin}%{highlight} in %{dir} failed with error %{exception}: %{reason}') % {
737 :highlight => Bold, :plugin => p[:name], :dir => p[:dir],
738 :exception => p[:reason].class, :reason => p[:reason],
739 } + if $1 && !p[:reason].backtrace.empty?
740 _('at %{backtrace}') % {:backtrace => p[:reason].backtrace.join(', ')}
745 when /ignored?\s*plugins?/
746 return _('no plugins were ignored') if @ignored.empty?
750 reason = p[:loaded] ? _('overruled by previous') : _(p[:reason].to_s)
751 ((tmp[p[:dir]] ||= Hash.new)[reason] ||= Array.new).push(p[:name])
754 return tmp.map do |dir, reasons|
755 # FIXME get rid of these string concatenations to make gettext easier
756 s = reasons.map { |r, list|
757 list.map { |_| _.sub(/\.rb$/, '') }.join(', ') + " (#{r})"
761 when /^(\S+)\s*(.*)$/
765 # Let's see if we can match a plugin by the given name
766 (core_modules + plugins).each { |p|
767 next unless p.name == key
769 return p.help(key, params)
770 rescue Exception => err
771 #rescue TimeoutError, StandardError, NameError, SyntaxError => err
772 error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
776 # Nope, let's see if it's a command, and ask for help at the corresponding botmodule
778 if commands.has_key?(k)
779 p = commands[k][:botmodule]
781 return p.help(key, params)
782 rescue Exception => err
783 #rescue TimeoutError, StandardError, NameError, SyntaxError => err
784 error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
792 @sorted_modules = (core_modules + plugins).sort do |a, b|
793 a.priority <=> b.priority
796 @delegate_list.each_value do |list|
797 list.sort! {|a,b| a.priority <=> b.priority}
801 # see if each plugin handles +method+, and if so, call it, passing
802 # +message+ as a parameter. botmodules are called in order of priority
803 # from lowest to highest.
805 # If the passed +message+ is marked as +#ignored?+, it will only be
806 # delegated to plugins with negative priority. Conversely, if it's
807 # a fake message (see BotModule#fake_message), it will only be
808 # delegated to plugins with positive priority.
810 # For delegation with more extensive options, see delegate_event
812 def delegate(method, *args)
813 opts = {:args => args}
815 if BasicUserMessage === m
816 # ignored messages should not be delegated
817 # to plugins with positive priority
818 opts[:below] = 0 if m.ignored?
819 # fake messages should not be delegated
820 # to plugins with negative priority
821 opts[:above] = 0 if m.recurse_depth > 0
823 delegate_event(method, opts)
826 # see if each plugin handles +method+, and if so, call it, passing
827 # +opts[:args]+ as a parameter. +opts[:above]+ and +opts[:below]+
828 # are used for a threshold of botmodule priorities that will be called.
829 # If :above is defined, only botmodules with a priority above the value
830 # will be called, for example. botmodules are called in order of
831 # priority from lowest to hightest.
832 def delegate_event(method, o={})
833 # if the priorities order of the delegate list is dirty,
834 # meaning some modules have been added or priorities have been
835 # changed, then the delegate list will need to be sorted before
836 # delegation. This should always be true for the first delegation.
837 sort_modules unless @sorted_modules
840 opts = {:args => []}.merge(o)
846 # debug "Delegating #{method.inspect}"
848 if method.match(DEFAULT_DELEGATE_PATTERNS)
849 debug "fast-delegating #{method}"
851 debug "no-one to delegate to" unless @delegate_list.has_key?(m)
852 return [] unless @delegate_list.has_key?(m)
853 @delegate_list[m].each { |p|
856 unless (above and above >= prio) or (below and below <= prio)
857 ret.push p.send(method, *(args||[]))
859 rescue Exception => err
860 raise if err.kind_of?(SystemExit)
861 error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
862 raise if err.kind_of?(BDB::Fatal)
866 debug "slow-delegating #{method}"
867 @sorted_modules.each { |p|
868 if(p.respond_to? method)
870 # debug "#{p.botmodule_class} #{p.name} responds"
872 unless (above and above >= prio) or (below and below <= prio)
873 ret.push p.send(method, *(args||[]))
875 rescue Exception => err
876 raise if err.kind_of?(SystemExit)
877 error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
878 raise if err.kind_of?(BDB::Fatal)
884 # debug "Finished delegating #{method.inspect}"
887 # see if we have a plugin that wants to handle this message, if so, pass
888 # it to the plugin and return true, otherwise false
890 debug "Delegating privmsg #{m.inspect} with pluginkey #{m.plugin.inspect}"
891 return unless m.plugin
893 if commands.has_key?(k)
894 p = commands[k][:botmodule]
895 a = commands[k][:auth]
896 # We check here for things that don't check themselves
897 # (e.g. mapped things)
898 debug "Checking auth ..."
899 if a.nil? || @bot.auth.allow?(a, m.source, m.replyto)
900 debug "Checking response ..."
901 if p.respond_to?("privmsg")
903 debug "#{p.botmodule_class} #{p.name} responds"
905 rescue Exception => err
906 raise if err.kind_of?(SystemExit)
907 error report_error("#{p.botmodule_class} #{p.name} privmsg() failed:", err)
908 raise if err.kind_of?(BDB::Fatal)
910 debug "Successfully delegated #{m.inspect}"
913 debug "#{p.botmodule_class} #{p.name} is registered, but it doesn't respond to privmsg()"
916 debug "#{p.botmodule_class} #{p.name} is registered, but #{m.source} isn't allowed to call #{m.plugin.inspect} on #{m.replyto}"
919 debug "Command #{k} isn't handled"
924 # delegate IRC messages, by delegating 'listen' first, and the actual method
925 # afterwards. Delegating 'privmsg' also delegates ctcp_listen and message
927 def irc_delegate(method, m)
928 delegate('listen', m)
929 if method.to_sym == :privmsg
930 delegate('ctcp_listen', m) if m.ctcp
931 delegate('message', m)
932 privmsg(m) if m.address?
933 delegate('unreplied', m) unless m.replied
940 # Returns the only PluginManagerClass instance
942 return PluginManagerClass.instance