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
184 @botmodule_triggers = Array.new
186 @handler = MessageMapper.new(self)
187 @registry = Registry::Accessor.new(@bot, self.class.to_s.gsub(/^.*::/, ""))
189 @manager.add_botmodule(self)
190 if self.respond_to?('set_language')
191 self.set_language(@bot.lang.language)
195 # Changing the value of @priority directly will cause problems,
196 # Please use priority=.
201 # Returns the symbol :BotModule
206 # Method called to flush the registry, thus ensuring that the botmodule's permanent
207 # data is committed to disk
210 # debug "Flushing #{@registry}"
214 # Method called to cleanup before the plugin is unloaded. If you overload
215 # this method to handle additional cleanup tasks, remember to call super()
216 # so that the default cleanup actions are taken care of as well.
219 # debug "Closing #{@registry}"
223 # Handle an Irc::PrivMessage for which this BotModule has a map. The method
224 # is called automatically and there is usually no need to call it
231 # Signal to other BotModules that an even happened.
233 def call_event(ev, *args)
234 @bot.plugins.delegate('event_' + ev.to_s.gsub(/[^\w\?!]+/, '_'), *(args.push Hash.new))
237 # call-seq: map(template, options)
239 # This is the preferred way to register the BotModule so that it
240 # responds to appropriately-formed messages on Irc.
246 # call-seq: map!(template, options)
248 # This is the same as map but doesn't register the new command
249 # as an alternative name for the plugin.
255 # Auxiliary method called by #map and #map!
256 def do_map(silent, *args)
257 @handler.map(self, *args)
261 self.register name, :auth => nil, :hidden => silent
262 @manager.register_map(self, map)
263 unless self.respond_to?('privmsg')
264 def self.privmsg(m) #:nodoc:
270 # Sets the default auth for command path _cmd_ to _val_ on channel _chan_:
271 # usually _chan_ is either "*" for everywhere, public and private (in which
272 # case it can be omitted) or "?" for private communications
274 def default_auth(cmd, val, chan="*")
281 Auth::defaultbotuser.set_default_permission(propose_default_path(c), val)
284 # Gets the default command path which would be given to command _cmd_
285 def propose_default_path(cmd)
286 [name, cmd].compact.join("::")
289 # Return an identifier for this plugin, defaults to a list of the message
290 # prefixes handled (used for error messages etc)
292 self.class.to_s.downcase.sub(/^#<module:.*?>::/,"").sub(/(plugin|module)?$/,"")
305 # Return a help string for your module. For complex modules, you may wish
306 # to break your help into topics, and return a list of available topics if
307 # +topic+ is nil. +plugin+ is passed containing the matching prefix for
308 # this message - if your plugin handles multiple prefixes, make sure you
309 # return the correct help for the prefix requested
310 def help(plugin, topic)
314 # Register the plugin as a handler for messages prefixed _cmd_.
316 # This can be called multiple times for a plugin to handle multiple message
319 # This command is now superceded by the #map() command, which should be used
320 # instead whenever possible.
322 def register(cmd, opts={})
323 raise ArgumentError, "Second argument must be a hash!" unless opts.kind_of?(Hash)
324 who = @manager.who_handles?(cmd)
326 raise "Command #{cmd} is already handled by #{who.botmodule_class} #{who}" if who != self
329 if opts.has_key?(:auth)
330 @manager.register(self, cmd, opts[:auth])
332 @manager.register(self, cmd, propose_default_path(cmd))
334 @botmodule_triggers << cmd unless opts.fetch(:hidden, false)
337 # Default usage method provided as a utility for simple plugins. The
338 # MessageMapper uses 'usage' as its default fallback method.
340 def usage(m, params = {})
341 m.reply(_("incorrect usage, ask for help using '%{command}'") % {:command => "#{@bot.nick}: help #{m.plugin}"})
344 # Define the priority of the module. During event delegation, lower
345 # priority modules will be called first. Default priority is 1
349 @bot.plugins.mark_priorities_dirty
353 # Directory name to be joined to the botclass to access data files. By
354 # default this is the plugin name itself, but may be overridden, for
355 # example by plugins that share their datafiles or for backwards
361 # Filename for a datafile built joining the botclass, plugin dirname and
364 @bot.path dirname, *fname
368 # A CoreBotModule is a BotModule that provides core functionality.
370 # This class should not be used by user plugins, as it's reserved for system
371 # plugins such as the ones that handle authentication, configuration and basic
374 class CoreBotModule < BotModule
380 # A Plugin is a BotModule that provides additional functionality.
382 # A user-defined plugin should subclass this, and then define any of the
383 # methods described in the documentation for BotModule to handle interaction
386 class Plugin < BotModule
392 # Singleton to manage multiple plugins and delegate messages to them for
394 class PluginManagerClass
397 attr_reader :botmodules
400 # This is the list of patterns commonly delegated to plugins.
401 # A fast delegation lookup is enabled for them.
402 DEFAULT_DELEGATE_PATTERNS = %r{^(?:
404 listen|ctcp_listen|privmsg|unreplied|
406 save|cleanup|flush_registry|
412 :CoreBotModule => [],
416 @names_hash = Hash.new
417 @commandmappers = Hash.new
420 # modules will be sorted on first delegate call
421 @sorted_modules = nil
423 @delegate_list = Hash.new { |h, k|
427 @core_module_dirs = []
437 ret = self.to_s[0..-2]
438 ret << ' corebotmodules='
439 ret << @botmodules[:CoreBotModule].map { |m|
443 ret << @botmodules[:Plugin].map { |m|
449 # Reset lists of botmodules
450 def reset_botmodule_lists
451 @botmodules[:CoreBotModule].clear
452 @botmodules[:Plugin].clear
454 @commandmappers.clear
456 @failures_shown = false
457 mark_priorities_dirty
460 # Associate with bot _bot_
461 def bot_associate(bot)
462 reset_botmodule_lists
466 # Returns the botmodule with the given _name_
468 @names_hash[name.to_sym]
471 # Returns +true+ if _cmd_ has already been registered as a command
472 def who_handles?(cmd)
473 return nil unless @commandmappers.has_key?(cmd.to_sym)
474 return @commandmappers[cmd.to_sym][:botmodule]
477 # Registers botmodule _botmodule_ with command _cmd_ and command path _auth_path_
478 def register(botmodule, cmd, auth_path)
479 raise TypeError, "First argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
480 @commandmappers[cmd.to_sym] = {:botmodule => botmodule, :auth => auth_path}
483 # Registers botmodule _botmodule_ with map _map_. This adds the map to the #maps hash
484 # which has three keys:
486 # botmodule:: the associated botmodule
487 # auth:: an array of auth keys checked by the map; the first is the full_auth_path of the map
488 # map:: the actual MessageTemplate object
491 def register_map(botmodule, map)
492 raise TypeError, "First argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
493 @maps[map.template] = { :botmodule => botmodule, :auth => [map.options[:full_auth_path]], :map => map }
496 def add_botmodule(botmodule)
497 raise TypeError, "Argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
498 kl = botmodule.botmodule_class
499 if @names_hash.has_key?(botmodule.to_sym)
500 case self[botmodule].botmodule_class
502 raise "#{kl} #{botmodule} already registered!"
504 raise "#{self[botmodule].botmodule_class} #{botmodule} already registered, cannot re-register as #{kl}"
507 @botmodules[kl] << botmodule
508 @names_hash[botmodule.to_sym] = botmodule
509 mark_priorities_dirty
512 # Returns an array of the loaded plugins
514 @botmodules[:CoreBotModule]
517 # Returns an array of the loaded plugins
522 # Returns a hash of the registered message prefixes and associated
528 # Tells the PluginManager that the next time it delegates an event, it
529 # should sort the modules by priority
530 def mark_priorities_dirty
531 @sorted_modules = nil
534 # Makes a string of error _err_ by adding text _str_
535 def report_error(str, err)
536 ([str, err.inspect] + err.backtrace).join("\n")
539 # This method is the one that actually loads a module from the
542 # _desc_ is a simple description of what we are loading (plugin/botmodule/whatever)
544 # It returns the Symbol :loaded on success, and an Exception
547 def load_botmodule_file(fname, desc=nil)
548 # create a new, anonymous module to "house" the plugin
549 # the idea here is to prevent namespace pollution. perhaps there
551 plugin_module = Module.new
552 # each plugin uses its own textdomain, we bind it automatically here
553 bindtextdomain_to(plugin_module, "rbot-#{File.basename(fname, '.rb')}")
555 desc = desc.to_s + " " if desc
558 plugin_string = IO.read(fname)
559 debug "loading #{desc}#{fname}"
560 plugin_module.module_eval(plugin_string, fname)
562 rescue Exception => err
563 # rescue TimeoutError, StandardError, NameError, LoadError, SyntaxError => err
564 error report_error("#{desc}#{fname} load failed", err)
565 bt = err.backtrace.select { |line|
566 line.match(/^(\(eval\)|#{fname}):\d+/)
569 el.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
573 msg = err.to_str.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
576 newerr = err.class.new(msg)
577 newerr.set_backtrace(bt)
581 private :load_botmodule_file
583 # add one or more directories to the list of directories to
584 # load core modules from
585 def add_core_module_dir(*dirlist)
586 @core_module_dirs += dirlist
587 debug "Core module loading paths: #{@core_module_dirs.join(', ')}"
590 # add one or more directories to the list of directories to
592 def add_plugin_dir(*dirlist)
593 @plugin_dirs += dirlist
594 debug "Plugin loading paths: #{@plugin_dirs.join(', ')}"
597 def clear_botmodule_dirs
598 @core_module_dirs.clear
600 debug "Core module and plugin loading paths cleared"
603 # load plugins from pre-assigned list of directories
611 @bot.config['plugins.blacklist'].each { |p|
613 processed[pn.intern] = :blacklisted
616 dirs = @core_module_dirs + @plugin_dirs
618 if(FileTest.directory?(dir))
622 next if(file =~ /^\./)
624 if processed.has_key?(file.intern)
625 @ignored << {:name => file, :dir => dir, :reason => processed[file.intern]}
629 if(file =~ /^(.+\.rb)\.disabled$/)
630 # GB: Do we want to do this? This means that a disabled plugin in a directory
631 # will disable in all subsequent directories. This was probably meant
632 # to be used before plugins.blacklist was implemented, so I think
633 # we don't need this anymore
634 processed[$1.intern] = :disabled
635 @ignored << {:name => $1, :dir => dir, :reason => processed[$1.intern]}
639 next unless(file =~ /\.rb$/)
641 did_it = load_botmodule_file("#{dir}/#{file}", "plugin")
644 processed[file.intern] = did_it
646 @failed << { :name => file, :dir => dir, :reason => did_it }
652 debug "finished loading plugins: #{status(true)}"
653 (core_modules + plugins).each { |p|
654 p.methods.grep(DEFAULT_DELEGATE_PATTERNS).each { |m|
655 @delegate_list[m.intern] << p
658 mark_priorities_dirty
661 # call the save method for each active plugin
663 delegate 'flush_registry'
667 # call the cleanup method for each active plugin
670 reset_botmodule_lists
673 # drop all plugins and rescan plugins on disk
674 # calls save and cleanup for each plugin before dropping them
681 def status(short=false)
683 if self.core_length > 0
685 output << n_("%{count} core module loaded", "%{count} core modules loaded",
686 self.core_length) % {:count => self.core_length}
688 output << n_("%{count} core module: %{list}",
689 "%{count} core modules: %{list}", self.core_length) %
690 { :count => self.core_length,
691 :list => core_modules.collect{ |p| p.name}.sort.join(", ") }
694 output << _("no core botmodules loaded")
696 # Active plugins first
699 output << n_("%{count} plugin loaded", "%{count} plugins loaded",
700 self.length) % {:count => self.length}
702 output << n_("%{count} plugin: %{list}",
703 "%{count} plugins: %{list}", self.length) %
704 { :count => self.length,
705 :list => plugins.collect{ |p| p.name}.sort.join(", ") }
708 output << "no plugins active"
710 # Ignored plugins next
711 unless @ignored.empty? or @failures_shown
713 output << n_("%{highlight}%{count} plugin ignored%{highlight}",
714 "%{highlight}%{count} plugins ignored%{highlight}",
716 { :count => @ignored.length, :highlight => Underline }
718 output << n_("%{highlight}%{count} plugin ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
719 "%{highlight}%{count} plugins ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
721 { :count => @ignored.length, :highlight => Underline,
722 :bold => Bold, :command => "help ignored plugins"}
725 # Failed plugins next
726 unless @failed.empty? or @failures_shown
728 output << n_("%{highlight}%{count} plugin failed to load%{highlight}",
729 "%{highlight}%{count} plugins failed to load%{highlight}",
731 { :count => @failed.length, :highlight => Reverse }
733 output << n_("%{highlight}%{count} plugin failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
734 "%{highlight}%{count} plugins failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
736 { :count => @failed.length, :highlight => Reverse,
737 :bold => Bold, :command => "help failed plugins"}
743 # return list of help topics (plugin names)
746 @failures_shown = true
758 # return help for +topic+ (call associated plugin's help method)
761 when /fail(?:ed)?\s*plugins?.*(trace(?:back)?s?)?/
762 # debug "Failures: #{@failed.inspect}"
763 return _("no plugins failed to load") if @failed.empty?
764 return @failed.collect { |p|
765 _('%{highlight}%{plugin}%{highlight} in %{dir} failed with error %{exception}: %{reason}') % {
766 :highlight => Bold, :plugin => p[:name], :dir => p[:dir],
767 :exception => p[:reason].class, :reason => p[:reason],
768 } + if $1 && !p[:reason].backtrace.empty?
769 _('at %{backtrace}') % {:backtrace => p[:reason].backtrace.join(', ')}
774 when /ignored?\s*plugins?/
775 return _('no plugins were ignored') if @ignored.empty?
779 reason = p[:loaded] ? _('overruled by previous') : _(p[:reason].to_s)
780 ((tmp[p[:dir]] ||= Hash.new)[reason] ||= Array.new).push(p[:name])
783 return tmp.map do |dir, reasons|
784 # FIXME get rid of these string concatenations to make gettext easier
785 s = reasons.map { |r, list|
786 list.map { |_| _.sub(/\.rb$/, '') }.join(', ') + " (#{r})"
790 when /^(\S+)\s*(.*)$/
794 # Let's see if we can match a plugin by the given name
795 (core_modules + plugins).each { |p|
796 next unless p.name == key
798 return p.help(key, params)
799 rescue Exception => err
800 #rescue TimeoutError, StandardError, NameError, SyntaxError => err
801 error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
805 # Nope, let's see if it's a command, and ask for help at the corresponding botmodule
807 if commands.has_key?(k)
808 p = commands[k][:botmodule]
810 return p.help(key, params)
811 rescue Exception => err
812 #rescue TimeoutError, StandardError, NameError, SyntaxError => err
813 error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
821 @sorted_modules = (core_modules + plugins).sort do |a, b|
822 a.priority <=> b.priority
825 @delegate_list.each_value do |list|
826 list.sort! {|a,b| a.priority <=> b.priority}
830 # call-seq: delegate</span><span class="method-args">(method, m, opts={})</span>
831 # <span class="method-name">delegate</span><span class="method-args">(method, opts={})
833 # see if each plugin handles _method_, and if so, call it, passing
834 # _m_ as a parameter (if present). BotModules are called in order of
835 # priority from lowest to highest.
837 # If the passed _m_ is a BasicUserMessage and is marked as #ignored?, it
838 # will only be delegated to plugins with negative priority. Conversely, if
839 # it's a fake message (see BotModule#fake_message), it will only be
840 # delegated to plugins with positive priority.
842 # Note that _m_ can also be an exploded Array, but in this case the last
843 # element of it cannot be a Hash, or it will be interpreted as the options
844 # Hash for delegate itself. The last element can be a subclass of a Hash, though.
845 # To be on the safe side, you can add an empty Hash as last parameter for delegate
846 # when calling it with an exploded Array:
847 # @bot.plugins.delegate(method, *(args.push Hash.new))
849 # Currently supported options are the following:
851 # if specified, the delegation will only consider plugins with a priority
852 # higher than the specified value
854 # if specified, the delegation will only consider plugins with a priority
855 # lower than the specified value
857 def delegate(method, *args)
858 # if the priorities order of the delegate list is dirty,
859 # meaning some modules have been added or priorities have been
860 # changed, then the delegate list will need to be sorted before
861 # delegation. This should always be true for the first delegation.
862 sort_modules unless @sorted_modules
865 opts.merge(args.pop) if args.last.class == Hash
868 if BasicUserMessage === m
869 # ignored messages should not be delegated
870 # to plugins with positive priority
871 opts[:below] ||= 0 if m.ignored?
872 # fake messages should not be delegated
873 # to plugins with negative priority
874 opts[:above] ||= 0 if m.recurse_depth > 0
880 # debug "Delegating #{method.inspect}"
882 if method.match(DEFAULT_DELEGATE_PATTERNS)
883 debug "fast-delegating #{method}"
885 debug "no-one to delegate to" unless @delegate_list.has_key?(m)
886 return [] unless @delegate_list.has_key?(m)
887 @delegate_list[m].each { |p|
890 unless (above and above >= prio) or (below and below <= prio)
891 ret.push p.send(method, *args)
893 rescue Exception => err
894 raise if err.kind_of?(SystemExit)
895 error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
896 raise if err.kind_of?(BDB::Fatal)
900 debug "slow-delegating #{method}"
901 @sorted_modules.each { |p|
902 if(p.respond_to? method)
904 # debug "#{p.botmodule_class} #{p.name} responds"
906 unless (above and above >= prio) or (below and below <= prio)
907 ret.push p.send(method, *args)
909 rescue Exception => err
910 raise if err.kind_of?(SystemExit)
911 error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
912 raise if err.kind_of?(BDB::Fatal)
918 # debug "Finished delegating #{method.inspect}"
921 # see if we have a plugin that wants to handle this message, if so, pass
922 # it to the plugin and return true, otherwise false
924 debug "Delegating privmsg #{m.inspect} with pluginkey #{m.plugin.inspect}"
925 return unless m.plugin
927 if commands.has_key?(k)
928 p = commands[k][:botmodule]
929 a = commands[k][:auth]
930 # We check here for things that don't check themselves
931 # (e.g. mapped things)
932 debug "Checking auth ..."
933 if a.nil? || @bot.auth.allow?(a, m.source, m.replyto)
934 debug "Checking response ..."
935 if p.respond_to?("privmsg")
937 debug "#{p.botmodule_class} #{p.name} responds"
939 rescue Exception => err
940 raise if err.kind_of?(SystemExit)
941 error report_error("#{p.botmodule_class} #{p.name} privmsg() failed:", err)
942 raise if err.kind_of?(BDB::Fatal)
944 debug "Successfully delegated #{m.inspect}"
947 debug "#{p.botmodule_class} #{p.name} is registered, but it doesn't respond to privmsg()"
950 debug "#{p.botmodule_class} #{p.name} is registered, but #{m.source} isn't allowed to call #{m.plugin.inspect} on #{m.replyto}"
953 debug "Command #{k} isn't handled"
958 # delegate IRC messages, by delegating 'listen' first, and the actual method
959 # afterwards. Delegating 'privmsg' also delegates ctcp_listen and message
961 def irc_delegate(method, m)
962 delegate('listen', m)
963 if method.to_sym == :privmsg
964 delegate('ctcp_listen', m) if m.ctcp
965 delegate('message', m)
966 privmsg(m) if m.address? and not m.ignored?
967 delegate('unreplied', m) unless m.replied
974 # Returns the only PluginManagerClass instance
976 return PluginManagerClass.instance