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.
130 Called when the Message Of The Day is fully
131 recevied from the server.
133 connect:: Called when a server is joined successfully, but
134 before autojoin channels are joined (no params)
136 set_language(String)::
137 Called when the user sets a new language
138 whose name is the given String
140 save:: Called when you are required to save your plugin's
141 state, if you maintain data between sessions
143 cleanup:: called before your plugin is "unloaded", prior to a
144 plugin reload or bot quit - close any open
145 files/connections or flush caches here
152 # the plugin registry
153 attr_reader :registry
155 # the message map handler
158 # Initialise your bot module. Always call super if you override this method,
159 # as important variables are set up for you:
164 # the botmodule's registry, which can be used to store permanent data
165 # (see Registry::Accessor for additional documentation)
167 # Other instance variables which are defined and should not be overwritten
168 # byt the user, but aren't usually accessed directly, are:
171 # the plugins manager instance
172 # @botmodule_triggers::
173 # an Array of words this plugin #register()ed itself for
175 # the MessageMapper that handles this plugin's maps
178 @manager = Plugins::manager
181 @botmodule_triggers = Array.new
183 @handler = MessageMapper.new(self)
184 @registry = Registry::Accessor.new(@bot, self.class.to_s.gsub(/^.*::/, ""))
186 @manager.add_botmodule(self)
187 if self.respond_to?('set_language')
188 self.set_language(@bot.lang.language)
192 # Changing the value of @priority directly will cause problems,
193 # Please use priority=.
198 # Returns the symbol :BotModule
203 # Method called to flush the registry, thus ensuring that the botmodule's permanent
204 # data is committed to disk
207 # debug "Flushing #{@registry}"
211 # Method called to cleanup before the plugin is unloaded. If you overload
212 # this method to handle additional cleanup tasks, remember to call super()
213 # so that the default cleanup actions are taken care of as well.
216 # debug "Closing #{@registry}"
220 # Handle an Irc::PrivMessage for which this BotModule has a map. The method
221 # is called automatically and there is usually no need to call it
228 # Signal to other BotModules that an even happened.
230 def call_event(ev, *args)
231 @bot.plugins.delegate('event_' + ev.to_s.gsub(/[^\w\?!]+/, '_'), *args)
234 # call-seq: map(template, options)
236 # This is the preferred way to register the BotModule so that it
237 # responds to appropriately-formed messages on Irc.
243 # call-seq: map!(template, options)
245 # This is the same as map but doesn't register the new command
246 # as an alternative name for the plugin.
252 # Auxiliary method called by #map and #map!
253 def do_map(silent, *args)
254 @handler.map(self, *args)
258 self.register name, :auth => nil, :hidden => silent
259 @manager.register_map(self, map)
260 unless self.respond_to?('privmsg')
261 def self.privmsg(m) #:nodoc:
267 # Sets the default auth for command path _cmd_ to _val_ on channel _chan_:
268 # usually _chan_ is either "*" for everywhere, public and private (in which
269 # case it can be omitted) or "?" for private communications
271 def default_auth(cmd, val, chan="*")
278 Auth::defaultbotuser.set_default_permission(propose_default_path(c), val)
281 # Gets the default command path which would be given to command _cmd_
282 def propose_default_path(cmd)
283 [name, cmd].compact.join("::")
286 # Return an identifier for this plugin, defaults to a list of the message
287 # prefixes handled (used for error messages etc)
289 self.class.to_s.downcase.sub(/^#<module:.*?>::/,"").sub(/(plugin|module)?$/,"")
302 # Return a help string for your module. For complex modules, you may wish
303 # to break your help into topics, and return a list of available topics if
304 # +topic+ is nil. +plugin+ is passed containing the matching prefix for
305 # this message - if your plugin handles multiple prefixes, make sure you
306 # return the correct help for the prefix requested
307 def help(plugin, topic)
311 # Register the plugin as a handler for messages prefixed _cmd_.
313 # This can be called multiple times for a plugin to handle multiple message
316 # This command is now superceded by the #map() command, which should be used
317 # instead whenever possible.
319 def register(cmd, opts={})
320 raise ArgumentError, "Second argument must be a hash!" unless opts.kind_of?(Hash)
321 who = @manager.who_handles?(cmd)
323 raise "Command #{cmd} is already handled by #{who.botmodule_class} #{who}" if who != self
326 if opts.has_key?(:auth)
327 @manager.register(self, cmd, opts[:auth])
329 @manager.register(self, cmd, propose_default_path(cmd))
331 @botmodule_triggers << cmd unless opts.fetch(:hidden, false)
334 # Default usage method provided as a utility for simple plugins. The
335 # MessageMapper uses 'usage' as its default fallback method.
337 def usage(m, params = {})
338 m.reply(_("incorrect usage, ask for help using '%{command}'") % {:command => "#{@bot.nick}: help #{m.plugin}"})
341 # Define the priority of the module. During event delegation, lower
342 # priority modules will be called first. Default priority is 1
346 @bot.plugins.mark_priorities_dirty
351 # A CoreBotModule is a BotModule that provides core functionality.
353 # This class should not be used by user plugins, as it's reserved for system
354 # plugins such as the ones that handle authentication, configuration and basic
357 class CoreBotModule < BotModule
363 # A Plugin is a BotModule that provides additional functionality.
365 # A user-defined plugin should subclass this, and then define any of the
366 # methods described in the documentation for BotModule to handle interaction
369 class Plugin < BotModule
375 # Singleton to manage multiple plugins and delegate messages to them for
377 class PluginManagerClass
380 attr_reader :botmodules
383 # This is the list of patterns commonly delegated to plugins.
384 # A fast delegation lookup is enabled for them.
385 DEFAULT_DELEGATE_PATTERNS = %r{^(?:
387 listen|ctcp_listen|privmsg|unreplied|
389 save|cleanup|flush_registry|
395 :CoreBotModule => [],
399 @names_hash = Hash.new
400 @commandmappers = Hash.new
403 # modules will be sorted on first delegate call
404 @sorted_modules = nil
406 @delegate_list = Hash.new { |h, k|
419 ret = self.to_s[0..-2]
420 ret << ' corebotmodules='
421 ret << @botmodules[:CoreBotModule].map { |m|
425 ret << @botmodules[:Plugin].map { |m|
431 # Reset lists of botmodules
432 def reset_botmodule_lists
433 @botmodules[:CoreBotModule].clear
434 @botmodules[:Plugin].clear
436 @commandmappers.clear
438 @failures_shown = false
439 mark_priorities_dirty
442 # Associate with bot _bot_
443 def bot_associate(bot)
444 reset_botmodule_lists
448 # Returns the botmodule with the given _name_
450 @names_hash[name.to_sym]
453 # Returns +true+ if _cmd_ has already been registered as a command
454 def who_handles?(cmd)
455 return nil unless @commandmappers.has_key?(cmd.to_sym)
456 return @commandmappers[cmd.to_sym][:botmodule]
459 # Registers botmodule _botmodule_ with command _cmd_ and command path _auth_path_
460 def register(botmodule, cmd, auth_path)
461 raise TypeError, "First argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
462 @commandmappers[cmd.to_sym] = {:botmodule => botmodule, :auth => auth_path}
465 # Registers botmodule _botmodule_ with map _map_. This adds the map to the #maps hash
466 # which has three keys:
468 # botmodule:: the associated botmodule
469 # auth:: an array of auth keys checked by the map; the first is the full_auth_path of the map
470 # map:: the actual MessageTemplate object
473 def register_map(botmodule, map)
474 raise TypeError, "First argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
475 @maps[map.template] = { :botmodule => botmodule, :auth => [map.options[:full_auth_path]], :map => map }
478 def add_botmodule(botmodule)
479 raise TypeError, "Argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
480 kl = botmodule.botmodule_class
481 if @names_hash.has_key?(botmodule.to_sym)
482 case self[botmodule].botmodule_class
484 raise "#{kl} #{botmodule} already registered!"
486 raise "#{self[botmodule].botmodule_class} #{botmodule} already registered, cannot re-register as #{kl}"
489 @botmodules[kl] << botmodule
490 @names_hash[botmodule.to_sym] = botmodule
491 mark_priorities_dirty
494 # Returns an array of the loaded plugins
496 @botmodules[:CoreBotModule]
499 # Returns an array of the loaded plugins
504 # Returns a hash of the registered message prefixes and associated
510 # Tells the PluginManager that the next time it delegates an event, it
511 # should sort the modules by priority
512 def mark_priorities_dirty
513 @sorted_modules = nil
516 # Makes a string of error _err_ by adding text _str_
517 def report_error(str, err)
518 ([str, err.inspect] + err.backtrace).join("\n")
521 # This method is the one that actually loads a module from the
524 # _desc_ is a simple description of what we are loading (plugin/botmodule/whatever)
526 # It returns the Symbol :loaded on success, and an Exception
529 def load_botmodule_file(fname, desc=nil)
530 # create a new, anonymous module to "house" the plugin
531 # the idea here is to prevent namespace pollution. perhaps there
533 plugin_module = Module.new
535 desc = desc.to_s + " " if desc
538 plugin_string = IO.readlines(fname).join("")
539 debug "loading #{desc}#{fname}"
540 plugin_module.module_eval(plugin_string, fname)
542 rescue Exception => err
543 # rescue TimeoutError, StandardError, NameError, LoadError, SyntaxError => err
544 error report_error("#{desc}#{fname} load failed", err)
545 bt = err.backtrace.select { |line|
546 line.match(/^(\(eval\)|#{fname}):\d+/)
549 el.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
553 msg = err.to_str.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
556 newerr = err.class.new(msg)
557 newerr.set_backtrace(bt)
561 private :load_botmodule_file
563 # add one or more directories to the list of directories to
564 # load botmodules from
566 # TODO find a way to specify necessary plugins which _must_ be loaded
568 def add_botmodule_dir(*dirlist)
570 debug "Botmodule loading path: #{@dirs.join(', ')}"
573 def clear_botmodule_dirs
575 debug "Botmodule loading path cleared"
578 # load plugins from pre-assigned list of directories
586 @bot.config['plugins.blacklist'].each { |p|
588 processed[pn.intern] = :blacklisted
593 if(FileTest.directory?(dir))
597 next if(file =~ /^\./)
599 if processed.has_key?(file.intern)
600 @ignored << {:name => file, :dir => dir, :reason => processed[file.intern]}
604 if(file =~ /^(.+\.rb)\.disabled$/)
605 # GB: Do we want to do this? This means that a disabled plugin in a directory
606 # will disable in all subsequent directories. This was probably meant
607 # to be used before plugins.blacklist was implemented, so I think
608 # we don't need this anymore
609 processed[$1.intern] = :disabled
610 @ignored << {:name => $1, :dir => dir, :reason => processed[$1.intern]}
614 next unless(file =~ /\.rb$/)
616 did_it = load_botmodule_file("#{dir}/#{file}", "plugin")
619 processed[file.intern] = did_it
621 @failed << { :name => file, :dir => dir, :reason => did_it }
627 debug "finished loading plugins: #{status(true)}"
628 (core_modules + plugins).each { |p|
629 p.methods.grep(DEFAULT_DELEGATE_PATTERNS).each { |m|
630 @delegate_list[m.intern] << p
633 mark_priorities_dirty
636 # call the save method for each active plugin
638 delegate 'flush_registry'
642 # call the cleanup method for each active plugin
645 reset_botmodule_lists
648 # drop all plugins and rescan plugins on disk
649 # calls save and cleanup for each plugin before dropping them
656 def status(short=false)
658 if self.core_length > 0
660 output << n_("%{count} core module loaded", "%{count} core modules loaded",
661 self.core_length) % {:count => self.core_length}
663 output << n_("%{count} core module: %{list}",
664 "%{count} core modules: %{list}", self.core_length) %
665 { :count => self.core_length,
666 :list => core_modules.collect{ |p| p.name}.sort.join(", ") }
669 output << _("no core botmodules loaded")
671 # Active plugins first
674 output << n_("%{count} plugin loaded", "%{count} plugins loaded",
675 self.length) % {:count => self.length}
677 output << n_("%{count} plugin: %{list}",
678 "%{count} plugins: %{list}", self.length) %
679 { :count => self.length,
680 :list => plugins.collect{ |p| p.name}.sort.join(", ") }
683 output << "no plugins active"
685 # Ignored plugins next
686 unless @ignored.empty? or @failures_shown
688 output << n_("%{highlight}%{count} plugin ignored%{highlight}",
689 "%{highlight}%{count} plugins ignored%{highlight}",
691 { :count => @ignored.length, :highlight => Underline }
693 output << n_("%{highlight}%{count} plugin ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
694 "%{highlight}%{count} plugins ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
696 { :count => @ignored.length, :highlight => Underline,
697 :bold => Bold, :command => "help ignored plugins"}
700 # Failed plugins next
701 unless @failed.empty? or @failures_shown
703 output << n_("%{highlight}%{count} plugin failed to load%{highlight}",
704 "%{highlight}%{count} plugins failed to load%{highlight}",
706 { :count => @failed.length, :highlight => Reverse }
708 output << n_("%{highlight}%{count} plugin failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
709 "%{highlight}%{count} plugins failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
711 { :count => @failed.length, :highlight => Reverse,
712 :bold => Bold, :command => "help failed plugins"}
718 # return list of help topics (plugin names)
721 @failures_shown = true
733 # return help for +topic+ (call associated plugin's help method)
736 when /fail(?:ed)?\s*plugins?.*(trace(?:back)?s?)?/
737 # debug "Failures: #{@failed.inspect}"
738 return _("no plugins failed to load") if @failed.empty?
739 return @failed.collect { |p|
740 _('%{highlight}%{plugin}%{highlight} in %{dir} failed with error %{exception}: %{reason}') % {
741 :highlight => Bold, :plugin => p[:name], :dir => p[:dir],
742 :exception => p[:reason].class, :reason => p[:reason],
743 } + if $1 && !p[:reason].backtrace.empty?
744 _('at %{backtrace}') % {:backtrace => p[:reason].backtrace.join(', ')}
749 when /ignored?\s*plugins?/
750 return _('no plugins were ignored') if @ignored.empty?
754 reason = p[:loaded] ? _('overruled by previous') : _(p[:reason].to_s)
755 ((tmp[p[:dir]] ||= Hash.new)[reason] ||= Array.new).push(p[:name])
758 return tmp.map do |dir, reasons|
759 # FIXME get rid of these string concatenations to make gettext easier
760 s = reasons.map { |r, list|
761 list.map { |_| _.sub(/\.rb$/, '') }.join(', ') + " (#{r})"
765 when /^(\S+)\s*(.*)$/
769 # Let's see if we can match a plugin by the given name
770 (core_modules + plugins).each { |p|
771 next unless p.name == key
773 return p.help(key, params)
774 rescue Exception => err
775 #rescue TimeoutError, StandardError, NameError, SyntaxError => err
776 error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
780 # Nope, let's see if it's a command, and ask for help at the corresponding botmodule
782 if commands.has_key?(k)
783 p = commands[k][:botmodule]
785 return p.help(key, params)
786 rescue Exception => err
787 #rescue TimeoutError, StandardError, NameError, SyntaxError => err
788 error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
796 @sorted_modules = (core_modules + plugins).sort do |a, b|
797 a.priority <=> b.priority
800 @delegate_list.each_value do |list|
801 list.sort! {|a,b| a.priority <=> b.priority}
805 # see if each plugin handles +method+, and if so, call it, passing
806 # +message+ as a parameter. botmodules are called in order of priority
807 # from lowest to highest.
809 # If the passed +message+ is marked as +#ignored?+, it will only be
810 # delegated to plugins with negative priority. Conversely, if it's
811 # a fake message (see BotModule#fake_message), it will only be
812 # delegated to plugins with positive priority.
814 # For delegation with more extensive options, see delegate_event
816 def delegate(method, *args)
817 opts = {:args => args}
819 if BasicUserMessage === m
820 # ignored messages should not be delegated
821 # to plugins with positive priority
822 opts[:below] = 0 if m.ignored?
823 # fake messages should not be delegated
824 # to plugins with negative priority
825 opts[:above] = 0 if m.recurse_depth > 0
827 delegate_event(method, opts)
830 # see if each plugin handles +method+, and if so, call it, passing
831 # +opts[:args]+ as a parameter. +opts[:above]+ and +opts[:below]+
832 # are used for a threshold of botmodule priorities that will be called.
833 # If :above is defined, only botmodules with a priority above the value
834 # will be called, for example. botmodules are called in order of
835 # priority from lowest to hightest.
836 def delegate_event(method, o={})
837 # if the priorities order of the delegate list is dirty,
838 # meaning some modules have been added or priorities have been
839 # changed, then the delegate list will need to be sorted before
840 # delegation. This should always be true for the first delegation.
841 sort_modules unless @sorted_modules
844 opts = {:args => []}.merge(o)
850 # debug "Delegating #{method.inspect}"
852 if method.match(DEFAULT_DELEGATE_PATTERNS)
853 debug "fast-delegating #{method}"
855 debug "no-one to delegate to" unless @delegate_list.has_key?(m)
856 return [] unless @delegate_list.has_key?(m)
857 @delegate_list[m].each { |p|
860 unless (above and above >= prio) or (below and below <= prio)
861 ret.push p.send(method, *(args||[]))
863 rescue Exception => err
864 raise if err.kind_of?(SystemExit)
865 error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
866 raise if err.kind_of?(BDB::Fatal)
870 debug "slow-delegating #{method}"
871 @sorted_modules.each { |p|
872 if(p.respond_to? method)
874 # debug "#{p.botmodule_class} #{p.name} responds"
876 unless (above and above >= prio) or (below and below <= prio)
877 ret.push p.send(method, *(args||[]))
879 rescue Exception => err
880 raise if err.kind_of?(SystemExit)
881 error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
882 raise if err.kind_of?(BDB::Fatal)
888 # debug "Finished delegating #{method.inspect}"
891 # see if we have a plugin that wants to handle this message, if so, pass
892 # it to the plugin and return true, otherwise false
894 debug "Delegating privmsg #{m.inspect} with pluginkey #{m.plugin.inspect}"
895 return unless m.plugin
897 if commands.has_key?(k)
898 p = commands[k][:botmodule]
899 a = commands[k][:auth]
900 # We check here for things that don't check themselves
901 # (e.g. mapped things)
902 debug "Checking auth ..."
903 if a.nil? || @bot.auth.allow?(a, m.source, m.replyto)
904 debug "Checking response ..."
905 if p.respond_to?("privmsg")
907 debug "#{p.botmodule_class} #{p.name} responds"
909 rescue Exception => err
910 raise if err.kind_of?(SystemExit)
911 error report_error("#{p.botmodule_class} #{p.name} privmsg() failed:", err)
912 raise if err.kind_of?(BDB::Fatal)
914 debug "Successfully delegated #{m.inspect}"
917 debug "#{p.botmodule_class} #{p.name} is registered, but it doesn't respond to privmsg()"
920 debug "#{p.botmodule_class} #{p.name} is registered, but #{m.source} isn't allowed to call #{m.plugin.inspect} on #{m.replyto}"
923 debug "Command #{k} isn't handled"
928 # delegate IRC messages, by delegating 'listen' first, and the actual method
929 # afterwards. Delegating 'privmsg' also delegates ctcp_listen and message
931 def irc_delegate(method, m)
932 delegate('listen', m)
933 if method.to_sym == :privmsg
934 delegate('ctcp_listen', m) if m.ctcp
935 delegate('message', m)
936 privmsg(m) if m.address?
937 delegate('unreplied', m) unless m.replied
944 # Returns the only PluginManagerClass instance
946 return PluginManagerClass.instance