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 welcome(WelcomeMessage)::
122 Called when the welcome message is received on
123 joining a server succesfully.
125 connect:: Called when a server is joined successfully, but
126 before autojoin channels are joined (no params)
128 set_language(String)::
129 Called when the user sets a new language
130 whose name is the given String
132 save:: Called when you are required to save your plugin's
133 state, if you maintain data between sessions
135 cleanup:: called before your plugin is "unloaded", prior to a
136 plugin reload or bot quit - close any open
137 files/connections or flush caches here
144 # the plugin registry
145 attr_reader :registry
147 # the message map handler
150 # Initialise your bot module. Always call super if you override this method,
151 # as important variables are set up for you:
156 # the botmodule's registry, which can be used to store permanent data
157 # (see Registry::Accessor for additional documentation)
159 # Other instance variables which are defined and should not be overwritten
160 # byt the user, but aren't usually accessed directly, are:
163 # the plugins manager instance
164 # @botmodule_triggers::
165 # an Array of words this plugin #register()ed itself for
167 # the MessageMapper that handles this plugin's maps
170 @manager = Plugins::manager
173 @botmodule_triggers = Array.new
175 @handler = MessageMapper.new(self)
176 @registry = Registry::Accessor.new(@bot, self.class.to_s.gsub(/^.*::/, ""))
178 @manager.add_botmodule(self)
179 if self.respond_to?('set_language')
180 self.set_language(@bot.lang.language)
184 # Changing the value of @priority directly will cause problems,
185 # Please use priority=.
190 # Returns the symbol :BotModule
195 # Method called to flush the registry, thus ensuring that the botmodule's permanent
196 # data is committed to disk
199 # debug "Flushing #{@registry}"
203 # Method called to cleanup before the plugin is unloaded. If you overload
204 # this method to handle additional cleanup tasks, remember to call super()
205 # so that the default cleanup actions are taken care of as well.
208 # debug "Closing #{@registry}"
212 # Handle an Irc::PrivMessage for which this BotModule has a map. The method
213 # is called automatically and there is usually no need to call it
220 # Signal to other BotModules that an even happened.
222 def call_event(ev, *args)
223 @bot.plugins.delegate('event_' + ev.to_s.gsub(/[^\w\?!]+/, '_'), *args)
226 # call-seq: map(template, options)
228 # This is the preferred way to register the BotModule so that it
229 # responds to appropriately-formed messages on Irc.
235 # call-seq: map!(template, options)
237 # This is the same as map but doesn't register the new command
238 # as an alternative name for the plugin.
244 # Auxiliary method called by #map and #map!
245 def do_map(silent, *args)
246 @handler.map(self, *args)
250 self.register name, :auth => nil, :hidden => silent
251 @manager.register_map(self, map)
252 unless self.respond_to?('privmsg')
253 def self.privmsg(m) #:nodoc:
259 # Sets the default auth for command path _cmd_ to _val_ on channel _chan_:
260 # usually _chan_ is either "*" for everywhere, public and private (in which
261 # case it can be omitted) or "?" for private communications
263 def default_auth(cmd, val, chan="*")
270 Auth::defaultbotuser.set_default_permission(propose_default_path(c), val)
273 # Gets the default command path which would be given to command _cmd_
274 def propose_default_path(cmd)
275 [name, cmd].compact.join("::")
278 # Return an identifier for this plugin, defaults to a list of the message
279 # prefixes handled (used for error messages etc)
281 self.class.to_s.downcase.sub(/^#<module:.*?>::/,"").sub(/(plugin|module)?$/,"")
294 # Return a help string for your module. For complex modules, you may wish
295 # to break your help into topics, and return a list of available topics if
296 # +topic+ is nil. +plugin+ is passed containing the matching prefix for
297 # this message - if your plugin handles multiple prefixes, make sure you
298 # return the correct help for the prefix requested
299 def help(plugin, topic)
303 # Register the plugin as a handler for messages prefixed _cmd_.
305 # This can be called multiple times for a plugin to handle multiple message
308 # This command is now superceded by the #map() command, which should be used
309 # instead whenever possible.
311 def register(cmd, opts={})
312 raise ArgumentError, "Second argument must be a hash!" unless opts.kind_of?(Hash)
313 who = @manager.who_handles?(cmd)
315 raise "Command #{cmd} is already handled by #{who.botmodule_class} #{who}" if who != self
318 if opts.has_key?(:auth)
319 @manager.register(self, cmd, opts[:auth])
321 @manager.register(self, cmd, propose_default_path(cmd))
323 @botmodule_triggers << cmd unless opts.fetch(:hidden, false)
326 # Default usage method provided as a utility for simple plugins. The
327 # MessageMapper uses 'usage' as its default fallback method.
329 def usage(m, params = {})
330 m.reply(_("incorrect usage, ask for help using '%{command}'") % {:command => "#{@bot.nick}: help #{m.plugin}"})
333 # Define the priority of the module. During event delegation, lower
334 # priority modules will be called first. Default priority is 1
338 @bot.plugins.mark_priorities_dirty
343 # A CoreBotModule is a BotModule that provides core functionality.
345 # This class should not be used by user plugins, as it's reserved for system
346 # plugins such as the ones that handle authentication, configuration and basic
349 class CoreBotModule < BotModule
355 # A Plugin is a BotModule that provides additional functionality.
357 # A user-defined plugin should subclass this, and then define any of the
358 # methods described in the documentation for BotModule to handle interaction
361 class Plugin < BotModule
367 # Singleton to manage multiple plugins and delegate messages to them for
369 class PluginManagerClass
372 attr_reader :botmodules
375 # This is the list of patterns commonly delegated to plugins.
376 # A fast delegation lookup is enabled for them.
377 DEFAULT_DELEGATE_PATTERNS = %r{^(?:
379 listen|ctcp_listen|privmsg|unreplied|
381 save|cleanup|flush_registry|
387 :CoreBotModule => [],
391 @names_hash = Hash.new
392 @commandmappers = Hash.new
395 # modules will be sorted on first delegate call
396 @sorted_modules = nil
398 @delegate_list = Hash.new { |h, k|
411 ret = self.to_s[0..-2]
412 ret << ' corebotmodules='
413 ret << @botmodules[:CoreBotModule].map { |m|
417 ret << @botmodules[:Plugin].map { |m|
423 # Reset lists of botmodules
424 def reset_botmodule_lists
425 @botmodules[:CoreBotModule].clear
426 @botmodules[:Plugin].clear
428 @commandmappers.clear
430 @failures_shown = false
431 mark_priorities_dirty
434 # Associate with bot _bot_
435 def bot_associate(bot)
436 reset_botmodule_lists
440 # Returns the botmodule with the given _name_
442 @names_hash[name.to_sym]
445 # Returns +true+ if _cmd_ has already been registered as a command
446 def who_handles?(cmd)
447 return nil unless @commandmappers.has_key?(cmd.to_sym)
448 return @commandmappers[cmd.to_sym][:botmodule]
451 # Registers botmodule _botmodule_ with command _cmd_ and command path _auth_path_
452 def register(botmodule, cmd, auth_path)
453 raise TypeError, "First argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
454 @commandmappers[cmd.to_sym] = {:botmodule => botmodule, :auth => auth_path}
457 # Registers botmodule _botmodule_ with map _map_. This adds the map to the #maps hash
458 # which has three keys:
460 # botmodule:: the associated botmodule
461 # auth:: an array of auth keys checked by the map; the first is the full_auth_path of the map
462 # map:: the actual MessageTemplate object
465 def register_map(botmodule, map)
466 raise TypeError, "First argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
467 @maps[map.template] = { :botmodule => botmodule, :auth => [map.options[:full_auth_path]], :map => map }
470 def add_botmodule(botmodule)
471 raise TypeError, "Argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
472 kl = botmodule.botmodule_class
473 if @names_hash.has_key?(botmodule.to_sym)
474 case self[botmodule].botmodule_class
476 raise "#{kl} #{botmodule} already registered!"
478 raise "#{self[botmodule].botmodule_class} #{botmodule} already registered, cannot re-register as #{kl}"
481 @botmodules[kl] << botmodule
482 @names_hash[botmodule.to_sym] = botmodule
483 mark_priorities_dirty
486 # Returns an array of the loaded plugins
488 @botmodules[:CoreBotModule]
491 # Returns an array of the loaded plugins
496 # Returns a hash of the registered message prefixes and associated
502 # Tells the PluginManager that the next time it delegates an event, it
503 # should sort the modules by priority
504 def mark_priorities_dirty
505 @sorted_modules = nil
508 # Makes a string of error _err_ by adding text _str_
509 def report_error(str, err)
510 ([str, err.inspect] + err.backtrace).join("\n")
513 # This method is the one that actually loads a module from the
516 # _desc_ is a simple description of what we are loading (plugin/botmodule/whatever)
518 # It returns the Symbol :loaded on success, and an Exception
521 def load_botmodule_file(fname, desc=nil)
522 # create a new, anonymous module to "house" the plugin
523 # the idea here is to prevent namespace pollution. perhaps there
525 plugin_module = Module.new
527 desc = desc.to_s + " " if desc
530 plugin_string = IO.readlines(fname).join("")
531 debug "loading #{desc}#{fname}"
532 plugin_module.module_eval(plugin_string, fname)
534 rescue Exception => err
535 # rescue TimeoutError, StandardError, NameError, LoadError, SyntaxError => err
536 error report_error("#{desc}#{fname} load failed", err)
537 bt = err.backtrace.select { |line|
538 line.match(/^(\(eval\)|#{fname}):\d+/)
541 el.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
545 msg = err.to_str.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
548 newerr = err.class.new(msg)
549 newerr.set_backtrace(bt)
553 private :load_botmodule_file
555 # add one or more directories to the list of directories to
556 # load botmodules from
558 # TODO find a way to specify necessary plugins which _must_ be loaded
560 def add_botmodule_dir(*dirlist)
562 debug "Botmodule loading path: #{@dirs.join(', ')}"
565 def clear_botmodule_dirs
567 debug "Botmodule loading path cleared"
570 # load plugins from pre-assigned list of directories
578 @bot.config['plugins.blacklist'].each { |p|
580 processed[pn.intern] = :blacklisted
585 if(FileTest.directory?(dir))
589 next if(file =~ /^\./)
591 if processed.has_key?(file.intern)
592 @ignored << {:name => file, :dir => dir, :reason => processed[file.intern]}
596 if(file =~ /^(.+\.rb)\.disabled$/)
597 # GB: Do we want to do this? This means that a disabled plugin in a directory
598 # will disable in all subsequent directories. This was probably meant
599 # to be used before plugins.blacklist was implemented, so I think
600 # we don't need this anymore
601 processed[$1.intern] = :disabled
602 @ignored << {:name => $1, :dir => dir, :reason => processed[$1.intern]}
606 next unless(file =~ /\.rb$/)
608 did_it = load_botmodule_file("#{dir}/#{file}", "plugin")
611 processed[file.intern] = did_it
613 @failed << { :name => file, :dir => dir, :reason => did_it }
619 debug "finished loading plugins: #{status(true)}"
620 (core_modules + plugins).each { |p|
621 p.methods.grep(DEFAULT_DELEGATE_PATTERNS).each { |m|
622 @delegate_list[m.intern] << p
625 mark_priorities_dirty
628 # call the save method for each active plugin
630 delegate 'flush_registry'
634 # call the cleanup method for each active plugin
637 reset_botmodule_lists
640 # drop all plugins and rescan plugins on disk
641 # calls save and cleanup for each plugin before dropping them
648 def status(short=false)
650 if self.core_length > 0
652 output << n_("%{count} core module loaded", "%{count} core modules loaded",
653 self.core_length) % {:count => self.core_length}
655 output << n_("%{count} core module: %{list}",
656 "%{count} core modules: %{list}", self.core_length) %
657 { :count => self.core_length,
658 :list => core_modules.collect{ |p| p.name}.sort.join(", ") }
661 output << _("no core botmodules loaded")
663 # Active plugins first
666 output << n_("%{count} plugin loaded", "%{count} plugins loaded",
667 self.length) % {:count => self.length}
669 output << n_("%{count} plugin: %{list}",
670 "%{count} plugins: %{list}", self.length) %
671 { :count => self.length,
672 :list => plugins.collect{ |p| p.name}.sort.join(", ") }
675 output << "no plugins active"
677 # Ignored plugins next
678 unless @ignored.empty? or @failures_shown
680 output << n_("%{highlight}%{count} plugin ignored%{highlight}",
681 "%{highlight}%{count} plugins ignored%{highlight}",
683 { :count => @ignored.length, :highlight => Underline }
685 output << n_("%{highlight}%{count} plugin ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
686 "%{highlight}%{count} plugins ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
688 { :count => @ignored.length, :highlight => Underline,
689 :bold => Bold, :command => "help ignored plugins"}
692 # Failed plugins next
693 unless @failed.empty? or @failures_shown
695 output << n_("%{highlight}%{count} plugin failed to load%{highlight}",
696 "%{highlight}%{count} plugins failed to load%{highlight}",
698 { :count => @failed.length, :highlight => Reverse }
700 output << n_("%{highlight}%{count} plugin failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
701 "%{highlight}%{count} plugins failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
703 { :count => @failed.length, :highlight => Reverse,
704 :bold => Bold, :command => "help failed plugins"}
710 # return list of help topics (plugin names)
713 @failures_shown = true
725 # return help for +topic+ (call associated plugin's help method)
728 when /fail(?:ed)?\s*plugins?.*(trace(?:back)?s?)?/
729 # debug "Failures: #{@failed.inspect}"
730 return _("no plugins failed to load") if @failed.empty?
731 return @failed.collect { |p|
732 _('%{highlight}%{plugin}%{highlight} in %{dir} failed with error %{exception}: %{reason}') % {
733 :highlight => Bold, :plugin => p[:name], :dir => p[:dir],
734 :exception => p[:reason].class, :reason => p[:reason],
735 } + if $1 && !p[:reason].backtrace.empty?
736 _('at %{backtrace}') % {:backtrace => p[:reason].backtrace.join(', ')}
741 when /ignored?\s*plugins?/
742 return _('no plugins were ignored') if @ignored.empty?
746 reason = p[:loaded] ? _('overruled by previous') : _(p[:reason].to_s)
747 ((tmp[p[:dir]] ||= Hash.new)[reason] ||= Array.new).push(p[:name])
750 return tmp.map do |dir, reasons|
751 # FIXME get rid of these string concatenations to make gettext easier
752 s = reasons.map { |r, list|
753 list.map { |_| _.sub(/\.rb$/, '') }.join(', ') + " (#{r})"
757 when /^(\S+)\s*(.*)$/
761 # Let's see if we can match a plugin by the given name
762 (core_modules + plugins).each { |p|
763 next unless p.name == key
765 return p.help(key, params)
766 rescue Exception => err
767 #rescue TimeoutError, StandardError, NameError, SyntaxError => err
768 error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
772 # Nope, let's see if it's a command, and ask for help at the corresponding botmodule
774 if commands.has_key?(k)
775 p = commands[k][:botmodule]
777 return p.help(key, params)
778 rescue Exception => err
779 #rescue TimeoutError, StandardError, NameError, SyntaxError => err
780 error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
788 @sorted_modules = (core_modules + plugins).sort do |a, b|
789 a.priority <=> b.priority
792 @delegate_list.each_value do |list|
793 list.sort! {|a,b| a.priority <=> b.priority}
797 # see if each plugin handles +method+, and if so, call it, passing
798 # +message+ as a parameter. botmodules are called in order of priority
799 # from lowest to highest.
801 # If the passed +message+ is marked as +#ignored?+, it will only be
802 # delegated to plugins with negative priority. Conversely, if it's
803 # a fake message (see BotModule#fake_message), it will only be
804 # delegated to plugins with positive priority.
806 # For delegation with more extensive options, see delegate_event
808 def delegate(method, *args)
809 opts = {:args => args}
811 if BasicUserMessage === m
812 # ignored messages should not be delegated
813 # to plugins with positive priority
814 opts[:below] = 0 if m.ignored?
815 # fake messages should not be delegated
816 # to plugins with negative priority
817 opts[:above] = 0 if m.recurse_depth > 0
819 delegate_event(method, opts)
822 # see if each plugin handles +method+, and if so, call it, passing
823 # +opts[:args]+ as a parameter. +opts[:above]+ and +opts[:below]+
824 # are used for a threshold of botmodule priorities that will be called.
825 # If :above is defined, only botmodules with a priority above the value
826 # will be called, for example. botmodules are called in order of
827 # priority from lowest to hightest.
828 def delegate_event(method, o={})
829 # if the priorities order of the delegate list is dirty,
830 # meaning some modules have been added or priorities have been
831 # changed, then the delegate list will need to be sorted before
832 # delegation. This should always be true for the first delegation.
833 sort_modules unless @sorted_modules
836 opts = {:args => []}.merge(o)
842 # debug "Delegating #{method.inspect}"
844 if method.match(DEFAULT_DELEGATE_PATTERNS)
845 debug "fast-delegating #{method}"
847 debug "no-one to delegate to" unless @delegate_list.has_key?(m)
848 return [] unless @delegate_list.has_key?(m)
849 @delegate_list[m].each { |p|
852 unless (above and above >= prio) or (below and below <= prio)
853 ret.push p.send(method, *(args||[]))
855 rescue Exception => err
856 raise if err.kind_of?(SystemExit)
857 error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
858 raise if err.kind_of?(BDB::Fatal)
862 debug "slow-delegating #{method}"
863 @sorted_modules.each { |p|
864 if(p.respond_to? method)
866 # debug "#{p.botmodule_class} #{p.name} responds"
868 unless (above and above >= prio) or (below and below <= prio)
869 ret.push p.send(method, *(args||[]))
871 rescue Exception => err
872 raise if err.kind_of?(SystemExit)
873 error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
874 raise if err.kind_of?(BDB::Fatal)
880 # debug "Finished delegating #{method.inspect}"
883 # see if we have a plugin that wants to handle this message, if so, pass
884 # it to the plugin and return true, otherwise false
886 debug "Delegating privmsg #{m.inspect} with pluginkey #{m.plugin.inspect}"
887 return unless m.plugin
889 if commands.has_key?(k)
890 p = commands[k][:botmodule]
891 a = commands[k][:auth]
892 # We check here for things that don't check themselves
893 # (e.g. mapped things)
894 debug "Checking auth ..."
895 if a.nil? || @bot.auth.allow?(a, m.source, m.replyto)
896 debug "Checking response ..."
897 if p.respond_to?("privmsg")
899 debug "#{p.botmodule_class} #{p.name} responds"
901 rescue Exception => err
902 raise if err.kind_of?(SystemExit)
903 error report_error("#{p.botmodule_class} #{p.name} privmsg() failed:", err)
904 raise if err.kind_of?(BDB::Fatal)
906 debug "Successfully delegated #{m.inspect}"
909 debug "#{p.botmodule_class} #{p.name} is registered, but it doesn't respond to privmsg()"
912 debug "#{p.botmodule_class} #{p.name} is registered, but #{m.source} isn't allowed to call #{m.plugin.inspect} on #{m.replyto}"
915 debug "Command #{k} isn't handled"
920 # delegate IRC messages, by delegating 'listen' first, and the actual method
921 # afterwards. Delegating 'privmsg' also delegates ctcp_listen and message
923 def irc_delegate(method, m)
924 delegate('listen', m)
925 if method.to_sym == :privmsg
926 delegate('ctcp_listen', m) if m.ctcp
927 delegate('message', m)
928 privmsg(m) if m.address?
929 delegate('unreplied', m) unless m.replied
936 # Returns the only PluginManagerClass instance
938 return PluginManagerClass.instance