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 modechange(ModeChangeMessage)::
122 Called when a User or Channel mode is changed
123 topic(TopicMessage)::
124 Called when a user (or the bot) changes a channel
127 welcome(WelcomeMessage)::
128 Called when the welcome message is received on
129 joining a server succesfully.
132 Called when the Message Of The Day is fully
133 recevied from the server.
135 connect:: Called when a server is joined successfully, but
136 before autojoin channels are joined (no params)
138 set_language(String)::
139 Called when the user sets a new language
140 whose name is the given String
142 save:: Called when you are required to save your plugin's
143 state, if you maintain data between sessions
145 cleanup:: called before your plugin is "unloaded", prior to a
146 plugin reload or bot quit - close any open
147 files/connections or flush caches here
154 # the plugin registry
155 attr_reader :registry
157 # the message map handler
160 # Initialise your bot module. Always call super if you override this method,
161 # as important variables are set up for you:
166 # the botmodule's registry, which can be used to store permanent data
167 # (see Registry::Accessor for additional documentation)
169 # Other instance variables which are defined and should not be overwritten
170 # byt the user, but aren't usually accessed directly, are:
173 # the plugins manager instance
174 # @botmodule_triggers::
175 # an Array of words this plugin #register()ed itself for
177 # the MessageMapper that handles this plugin's maps
180 @manager = Plugins::manager
183 @botmodule_triggers = Array.new
185 @handler = MessageMapper.new(self)
186 @registry = Registry::Accessor.new(@bot, self.class.to_s.gsub(/^.*::/, ""))
188 @manager.add_botmodule(self)
189 if self.respond_to?('set_language')
190 self.set_language(@bot.lang.language)
194 # Changing the value of @priority directly will cause problems,
195 # Please use priority=.
200 # Returns the symbol :BotModule
205 # Method called to flush the registry, thus ensuring that the botmodule's permanent
206 # data is committed to disk
209 # debug "Flushing #{@registry}"
213 # Method called to cleanup before the plugin is unloaded. If you overload
214 # this method to handle additional cleanup tasks, remember to call super()
215 # so that the default cleanup actions are taken care of as well.
218 # debug "Closing #{@registry}"
222 # Handle an Irc::PrivMessage for which this BotModule has a map. The method
223 # is called automatically and there is usually no need to call it
230 # Signal to other BotModules that an even happened.
232 def call_event(ev, *args)
233 @bot.plugins.delegate('event_' + ev.to_s.gsub(/[^\w\?!]+/, '_'), *args)
236 # call-seq: map(template, options)
238 # This is the preferred way to register the BotModule so that it
239 # responds to appropriately-formed messages on Irc.
245 # call-seq: map!(template, options)
247 # This is the same as map but doesn't register the new command
248 # as an alternative name for the plugin.
254 # Auxiliary method called by #map and #map!
255 def do_map(silent, *args)
256 @handler.map(self, *args)
260 self.register name, :auth => nil, :hidden => silent
261 @manager.register_map(self, map)
262 unless self.respond_to?('privmsg')
263 def self.privmsg(m) #:nodoc:
269 # Sets the default auth for command path _cmd_ to _val_ on channel _chan_:
270 # usually _chan_ is either "*" for everywhere, public and private (in which
271 # case it can be omitted) or "?" for private communications
273 def default_auth(cmd, val, chan="*")
280 Auth::defaultbotuser.set_default_permission(propose_default_path(c), val)
283 # Gets the default command path which would be given to command _cmd_
284 def propose_default_path(cmd)
285 [name, cmd].compact.join("::")
288 # Return an identifier for this plugin, defaults to a list of the message
289 # prefixes handled (used for error messages etc)
291 self.class.to_s.downcase.sub(/^#<module:.*?>::/,"").sub(/(plugin|module)?$/,"")
304 # Return a help string for your module. For complex modules, you may wish
305 # to break your help into topics, and return a list of available topics if
306 # +topic+ is nil. +plugin+ is passed containing the matching prefix for
307 # this message - if your plugin handles multiple prefixes, make sure you
308 # return the correct help for the prefix requested
309 def help(plugin, topic)
313 # Register the plugin as a handler for messages prefixed _cmd_.
315 # This can be called multiple times for a plugin to handle multiple message
318 # This command is now superceded by the #map() command, which should be used
319 # instead whenever possible.
321 def register(cmd, opts={})
322 raise ArgumentError, "Second argument must be a hash!" unless opts.kind_of?(Hash)
323 who = @manager.who_handles?(cmd)
325 raise "Command #{cmd} is already handled by #{who.botmodule_class} #{who}" if who != self
328 if opts.has_key?(:auth)
329 @manager.register(self, cmd, opts[:auth])
331 @manager.register(self, cmd, propose_default_path(cmd))
333 @botmodule_triggers << cmd unless opts.fetch(:hidden, false)
336 # Default usage method provided as a utility for simple plugins. The
337 # MessageMapper uses 'usage' as its default fallback method.
339 def usage(m, params = {})
340 m.reply(_("incorrect usage, ask for help using '%{command}'") % {:command => "#{@bot.nick}: help #{m.plugin}"})
343 # Define the priority of the module. During event delegation, lower
344 # priority modules will be called first. Default priority is 1
348 @bot.plugins.mark_priorities_dirty
353 # A CoreBotModule is a BotModule that provides core functionality.
355 # This class should not be used by user plugins, as it's reserved for system
356 # plugins such as the ones that handle authentication, configuration and basic
359 class CoreBotModule < BotModule
365 # A Plugin is a BotModule that provides additional functionality.
367 # A user-defined plugin should subclass this, and then define any of the
368 # methods described in the documentation for BotModule to handle interaction
371 class Plugin < BotModule
377 # Singleton to manage multiple plugins and delegate messages to them for
379 class PluginManagerClass
382 attr_reader :botmodules
385 # This is the list of patterns commonly delegated to plugins.
386 # A fast delegation lookup is enabled for them.
387 DEFAULT_DELEGATE_PATTERNS = %r{^(?:
389 listen|ctcp_listen|privmsg|unreplied|
391 save|cleanup|flush_registry|
397 :CoreBotModule => [],
401 @names_hash = Hash.new
402 @commandmappers = Hash.new
405 # modules will be sorted on first delegate call
406 @sorted_modules = nil
408 @delegate_list = Hash.new { |h, k|
421 ret = self.to_s[0..-2]
422 ret << ' corebotmodules='
423 ret << @botmodules[:CoreBotModule].map { |m|
427 ret << @botmodules[:Plugin].map { |m|
433 # Reset lists of botmodules
434 def reset_botmodule_lists
435 @botmodules[:CoreBotModule].clear
436 @botmodules[:Plugin].clear
438 @commandmappers.clear
440 @failures_shown = false
441 mark_priorities_dirty
444 # Associate with bot _bot_
445 def bot_associate(bot)
446 reset_botmodule_lists
450 # Returns the botmodule with the given _name_
452 @names_hash[name.to_sym]
455 # Returns +true+ if _cmd_ has already been registered as a command
456 def who_handles?(cmd)
457 return nil unless @commandmappers.has_key?(cmd.to_sym)
458 return @commandmappers[cmd.to_sym][:botmodule]
461 # Registers botmodule _botmodule_ with command _cmd_ and command path _auth_path_
462 def register(botmodule, cmd, auth_path)
463 raise TypeError, "First argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
464 @commandmappers[cmd.to_sym] = {:botmodule => botmodule, :auth => auth_path}
467 # Registers botmodule _botmodule_ with map _map_. This adds the map to the #maps hash
468 # which has three keys:
470 # botmodule:: the associated botmodule
471 # auth:: an array of auth keys checked by the map; the first is the full_auth_path of the map
472 # map:: the actual MessageTemplate object
475 def register_map(botmodule, map)
476 raise TypeError, "First argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
477 @maps[map.template] = { :botmodule => botmodule, :auth => [map.options[:full_auth_path]], :map => map }
480 def add_botmodule(botmodule)
481 raise TypeError, "Argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
482 kl = botmodule.botmodule_class
483 if @names_hash.has_key?(botmodule.to_sym)
484 case self[botmodule].botmodule_class
486 raise "#{kl} #{botmodule} already registered!"
488 raise "#{self[botmodule].botmodule_class} #{botmodule} already registered, cannot re-register as #{kl}"
491 @botmodules[kl] << botmodule
492 @names_hash[botmodule.to_sym] = botmodule
493 mark_priorities_dirty
496 # Returns an array of the loaded plugins
498 @botmodules[:CoreBotModule]
501 # Returns an array of the loaded plugins
506 # Returns a hash of the registered message prefixes and associated
512 # Tells the PluginManager that the next time it delegates an event, it
513 # should sort the modules by priority
514 def mark_priorities_dirty
515 @sorted_modules = nil
518 # Makes a string of error _err_ by adding text _str_
519 def report_error(str, err)
520 ([str, err.inspect] + err.backtrace).join("\n")
523 # This method is the one that actually loads a module from the
526 # _desc_ is a simple description of what we are loading (plugin/botmodule/whatever)
528 # It returns the Symbol :loaded on success, and an Exception
531 def load_botmodule_file(fname, desc=nil)
532 # create a new, anonymous module to "house" the plugin
533 # the idea here is to prevent namespace pollution. perhaps there
535 plugin_module = Module.new
537 desc = desc.to_s + " " if desc
540 plugin_string = IO.readlines(fname).join("")
541 debug "loading #{desc}#{fname}"
542 plugin_module.module_eval(plugin_string, fname)
544 rescue Exception => err
545 # rescue TimeoutError, StandardError, NameError, LoadError, SyntaxError => err
546 error report_error("#{desc}#{fname} load failed", err)
547 bt = err.backtrace.select { |line|
548 line.match(/^(\(eval\)|#{fname}):\d+/)
551 el.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
555 msg = err.to_str.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
558 newerr = err.class.new(msg)
559 newerr.set_backtrace(bt)
563 private :load_botmodule_file
565 # add one or more directories to the list of directories to
566 # load botmodules from
568 # TODO find a way to specify necessary plugins which _must_ be loaded
570 def add_botmodule_dir(*dirlist)
572 debug "Botmodule loading path: #{@dirs.join(', ')}"
575 def clear_botmodule_dirs
577 debug "Botmodule loading path cleared"
580 # load plugins from pre-assigned list of directories
588 @bot.config['plugins.blacklist'].each { |p|
590 processed[pn.intern] = :blacklisted
595 if(FileTest.directory?(dir))
599 next if(file =~ /^\./)
601 if processed.has_key?(file.intern)
602 @ignored << {:name => file, :dir => dir, :reason => processed[file.intern]}
606 if(file =~ /^(.+\.rb)\.disabled$/)
607 # GB: Do we want to do this? This means that a disabled plugin in a directory
608 # will disable in all subsequent directories. This was probably meant
609 # to be used before plugins.blacklist was implemented, so I think
610 # we don't need this anymore
611 processed[$1.intern] = :disabled
612 @ignored << {:name => $1, :dir => dir, :reason => processed[$1.intern]}
616 next unless(file =~ /\.rb$/)
618 did_it = load_botmodule_file("#{dir}/#{file}", "plugin")
621 processed[file.intern] = did_it
623 @failed << { :name => file, :dir => dir, :reason => did_it }
629 debug "finished loading plugins: #{status(true)}"
630 (core_modules + plugins).each { |p|
631 p.methods.grep(DEFAULT_DELEGATE_PATTERNS).each { |m|
632 @delegate_list[m.intern] << p
635 mark_priorities_dirty
638 # call the save method for each active plugin
640 delegate 'flush_registry'
644 # call the cleanup method for each active plugin
647 reset_botmodule_lists
650 # drop all plugins and rescan plugins on disk
651 # calls save and cleanup for each plugin before dropping them
658 def status(short=false)
660 if self.core_length > 0
662 output << n_("%{count} core module loaded", "%{count} core modules loaded",
663 self.core_length) % {:count => self.core_length}
665 output << n_("%{count} core module: %{list}",
666 "%{count} core modules: %{list}", self.core_length) %
667 { :count => self.core_length,
668 :list => core_modules.collect{ |p| p.name}.sort.join(", ") }
671 output << _("no core botmodules loaded")
673 # Active plugins first
676 output << n_("%{count} plugin loaded", "%{count} plugins loaded",
677 self.length) % {:count => self.length}
679 output << n_("%{count} plugin: %{list}",
680 "%{count} plugins: %{list}", self.length) %
681 { :count => self.length,
682 :list => plugins.collect{ |p| p.name}.sort.join(", ") }
685 output << "no plugins active"
687 # Ignored plugins next
688 unless @ignored.empty? or @failures_shown
690 output << n_("%{highlight}%{count} plugin ignored%{highlight}",
691 "%{highlight}%{count} plugins ignored%{highlight}",
693 { :count => @ignored.length, :highlight => Underline }
695 output << n_("%{highlight}%{count} plugin ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
696 "%{highlight}%{count} plugins ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
698 { :count => @ignored.length, :highlight => Underline,
699 :bold => Bold, :command => "help ignored plugins"}
702 # Failed plugins next
703 unless @failed.empty? or @failures_shown
705 output << n_("%{highlight}%{count} plugin failed to load%{highlight}",
706 "%{highlight}%{count} plugins failed to load%{highlight}",
708 { :count => @failed.length, :highlight => Reverse }
710 output << n_("%{highlight}%{count} plugin failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
711 "%{highlight}%{count} plugins failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
713 { :count => @failed.length, :highlight => Reverse,
714 :bold => Bold, :command => "help failed plugins"}
720 # return list of help topics (plugin names)
723 @failures_shown = true
735 # return help for +topic+ (call associated plugin's help method)
738 when /fail(?:ed)?\s*plugins?.*(trace(?:back)?s?)?/
739 # debug "Failures: #{@failed.inspect}"
740 return _("no plugins failed to load") if @failed.empty?
741 return @failed.collect { |p|
742 _('%{highlight}%{plugin}%{highlight} in %{dir} failed with error %{exception}: %{reason}') % {
743 :highlight => Bold, :plugin => p[:name], :dir => p[:dir],
744 :exception => p[:reason].class, :reason => p[:reason],
745 } + if $1 && !p[:reason].backtrace.empty?
746 _('at %{backtrace}') % {:backtrace => p[:reason].backtrace.join(', ')}
751 when /ignored?\s*plugins?/
752 return _('no plugins were ignored') if @ignored.empty?
756 reason = p[:loaded] ? _('overruled by previous') : _(p[:reason].to_s)
757 ((tmp[p[:dir]] ||= Hash.new)[reason] ||= Array.new).push(p[:name])
760 return tmp.map do |dir, reasons|
761 # FIXME get rid of these string concatenations to make gettext easier
762 s = reasons.map { |r, list|
763 list.map { |_| _.sub(/\.rb$/, '') }.join(', ') + " (#{r})"
767 when /^(\S+)\s*(.*)$/
771 # Let's see if we can match a plugin by the given name
772 (core_modules + plugins).each { |p|
773 next unless p.name == key
775 return p.help(key, params)
776 rescue Exception => err
777 #rescue TimeoutError, StandardError, NameError, SyntaxError => err
778 error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
782 # Nope, let's see if it's a command, and ask for help at the corresponding botmodule
784 if commands.has_key?(k)
785 p = commands[k][:botmodule]
787 return p.help(key, params)
788 rescue Exception => err
789 #rescue TimeoutError, StandardError, NameError, SyntaxError => err
790 error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
798 @sorted_modules = (core_modules + plugins).sort do |a, b|
799 a.priority <=> b.priority
802 @delegate_list.each_value do |list|
803 list.sort! {|a,b| a.priority <=> b.priority}
807 # see if each plugin handles +method+, and if so, call it, passing
808 # +message+ as a parameter. botmodules are called in order of priority
809 # from lowest to highest.
811 # If the passed +message+ is marked as +#ignored?+, it will only be
812 # delegated to plugins with negative priority. Conversely, if it's
813 # a fake message (see BotModule#fake_message), it will only be
814 # delegated to plugins with positive priority.
816 # For delegation with more extensive options, see delegate_event
818 def delegate(method, *args)
819 opts = {:args => args}
821 if BasicUserMessage === m
822 # ignored messages should not be delegated
823 # to plugins with positive priority
824 opts[:below] = 0 if m.ignored?
825 # fake messages should not be delegated
826 # to plugins with negative priority
827 opts[:above] = 0 if m.recurse_depth > 0
829 delegate_event(method, opts)
832 # see if each plugin handles +method+, and if so, call it, passing
833 # +opts[:args]+ as a parameter. +opts[:above]+ and +opts[:below]+
834 # are used for a threshold of botmodule priorities that will be called.
835 # If :above is defined, only botmodules with a priority above the value
836 # will be called, for example. botmodules are called in order of
837 # priority from lowest to hightest.
838 def delegate_event(method, o={})
839 # if the priorities order of the delegate list is dirty,
840 # meaning some modules have been added or priorities have been
841 # changed, then the delegate list will need to be sorted before
842 # delegation. This should always be true for the first delegation.
843 sort_modules unless @sorted_modules
846 opts = {:args => []}.merge(o)
852 # debug "Delegating #{method.inspect}"
854 if method.match(DEFAULT_DELEGATE_PATTERNS)
855 debug "fast-delegating #{method}"
857 debug "no-one to delegate to" unless @delegate_list.has_key?(m)
858 return [] unless @delegate_list.has_key?(m)
859 @delegate_list[m].each { |p|
862 unless (above and above >= prio) or (below and below <= prio)
863 ret.push p.send(method, *(args||[]))
865 rescue Exception => err
866 raise if err.kind_of?(SystemExit)
867 error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
868 raise if err.kind_of?(BDB::Fatal)
872 debug "slow-delegating #{method}"
873 @sorted_modules.each { |p|
874 if(p.respond_to? method)
876 # debug "#{p.botmodule_class} #{p.name} responds"
878 unless (above and above >= prio) or (below and below <= prio)
879 ret.push p.send(method, *(args||[]))
881 rescue Exception => err
882 raise if err.kind_of?(SystemExit)
883 error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
884 raise if err.kind_of?(BDB::Fatal)
890 # debug "Finished delegating #{method.inspect}"
893 # see if we have a plugin that wants to handle this message, if so, pass
894 # it to the plugin and return true, otherwise false
896 debug "Delegating privmsg #{m.inspect} with pluginkey #{m.plugin.inspect}"
897 return unless m.plugin
899 if commands.has_key?(k)
900 p = commands[k][:botmodule]
901 a = commands[k][:auth]
902 # We check here for things that don't check themselves
903 # (e.g. mapped things)
904 debug "Checking auth ..."
905 if a.nil? || @bot.auth.allow?(a, m.source, m.replyto)
906 debug "Checking response ..."
907 if p.respond_to?("privmsg")
909 debug "#{p.botmodule_class} #{p.name} responds"
911 rescue Exception => err
912 raise if err.kind_of?(SystemExit)
913 error report_error("#{p.botmodule_class} #{p.name} privmsg() failed:", err)
914 raise if err.kind_of?(BDB::Fatal)
916 debug "Successfully delegated #{m.inspect}"
919 debug "#{p.botmodule_class} #{p.name} is registered, but it doesn't respond to privmsg()"
922 debug "#{p.botmodule_class} #{p.name} is registered, but #{m.source} isn't allowed to call #{m.plugin.inspect} on #{m.replyto}"
925 debug "Command #{k} isn't handled"
930 # delegate IRC messages, by delegating 'listen' first, and the actual method
931 # afterwards. Delegating 'privmsg' also delegates ctcp_listen and message
933 def irc_delegate(method, m)
934 delegate('listen', m)
935 if method.to_sym == :privmsg
936 delegate('ctcp_listen', m) if m.ctcp
937 delegate('message', m)
938 privmsg(m) if m.address?
939 delegate('unreplied', m) unless m.replied
946 # Returns the only PluginManagerClass instance
948 return PluginManagerClass.instance