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 privmsg(PrivMessage)::
86 Called for a PRIVMSG if the first word matches one
87 the plugin #register()ed for. Use m.plugin to get
88 that word and m.params for the rest of the message,
91 unreplied(PrivMessage)::
92 Called for a PRIVMSG which has not been replied to.
95 Called when a user (or the bot) is kicked from a
96 channel the bot is in.
98 invite(InviteMessage)::
99 Called when the bot is invited to a channel.
102 Called when a user (or the bot) joins a channel
105 Called when a user (or the bot) parts a channel
108 Called when a user (or the bot) quits IRC
111 Called when a user (or the bot) changes Nick
112 topic(TopicMessage)::
113 Called when a user (or the bot) changes a channel
116 connect:: Called when a server is joined successfully, but
117 before autojoin channels are joined (no params)
119 set_language(String)::
120 Called when the user sets a new language
121 whose name is the given String
123 save:: Called when you are required to save your plugin's
124 state, if you maintain data between sessions
126 cleanup:: called before your plugin is "unloaded", prior to a
127 plugin reload or bot quit - close any open
128 files/connections or flush caches here
132 attr_reader :bot # the associated bot
134 # Initialise your bot module. Always call super if you override this method,
135 # as important variables are set up for you:
140 # the botmodule's registry, which can be used to store permanent data
141 # (see Registry::Accessor for additional documentation)
143 # Other instance variables which are defined and should not be overwritten
144 # byt the user, but aren't usually accessed directly, are:
147 # the plugins manager instance
148 # @botmodule_triggers::
149 # an Array of words this plugin #register()ed itself for
151 # the MessageMapper that handles this plugin's maps
154 @manager = Plugins::manager
157 @botmodule_triggers = Array.new
159 @handler = MessageMapper.new(self)
160 @registry = Registry::Accessor.new(@bot, self.class.to_s.gsub(/^.*::/, ""))
162 @manager.add_botmodule(self)
163 if self.respond_to?('set_language')
164 self.set_language(@bot.lang.language)
168 # Returns the symbol :BotModule
173 # Method called to flush the registry, thus ensuring that the botmodule's permanent
174 # data is committed to disk
177 # debug "Flushing #{@registry}"
181 # Method called to cleanup before the plugin is unloaded. If you overload
182 # this method to handle additional cleanup tasks, remember to call super()
183 # so that the default cleanup actions are taken care of as well.
186 # debug "Closing #{@registry}"
190 # Handle an Irc::PrivMessage for which this BotModule has a map. The method
191 # is called automatically and there is usually no need to call it
198 # Signal to other BotModules that an even happened.
200 def call_event(ev, *args)
201 @bot.plugins.delegate('event_' + ev.to_s.gsub(/[^\w\?!]+/, '_'), *args)
204 # call-seq: map(template, options)
206 # This is the preferred way to register the BotModule so that it
207 # responds to appropriately-formed messages on Irc.
213 # call-seq: map!(template, options)
215 # This is the same as map but doesn't register the new command
216 # as an alternative name for the plugin.
222 # Auxiliary method called by #map and #map!
223 def do_map(silent, *args)
224 @handler.map(self, *args)
228 self.register name, :auth => nil, :hidden => silent
229 @manager.register_map(self, map)
230 unless self.respond_to?('privmsg')
231 def self.privmsg(m) #:nodoc:
237 # Sets the default auth for command path _cmd_ to _val_ on channel _chan_:
238 # usually _chan_ is either "*" for everywhere, public and private (in which
239 # case it can be omitted) or "?" for private communications
241 def default_auth(cmd, val, chan="*")
248 Auth::defaultbotuser.set_default_permission(propose_default_path(c), val)
251 # Gets the default command path which would be given to command _cmd_
252 def propose_default_path(cmd)
253 [name, cmd].compact.join("::")
256 # Return an identifier for this plugin, defaults to a list of the message
257 # prefixes handled (used for error messages etc)
259 self.class.to_s.downcase.sub(/^#<module:.*?>::/,"").sub(/(plugin|module)?$/,"")
272 # Return a help string for your module. For complex modules, you may wish
273 # to break your help into topics, and return a list of available topics if
274 # +topic+ is nil. +plugin+ is passed containing the matching prefix for
275 # this message - if your plugin handles multiple prefixes, make sure you
276 # return the correct help for the prefix requested
277 def help(plugin, topic)
281 # Register the plugin as a handler for messages prefixed _cmd_.
283 # This can be called multiple times for a plugin to handle multiple message
286 # This command is now superceded by the #map() command, which should be used
287 # instead whenever possible.
289 def register(cmd, opts={})
290 raise ArgumentError, "Second argument must be a hash!" unless opts.kind_of?(Hash)
291 who = @manager.who_handles?(cmd)
293 raise "Command #{cmd} is already handled by #{who.botmodule_class} #{who}" if who != self
296 if opts.has_key?(:auth)
297 @manager.register(self, cmd, opts[:auth])
299 @manager.register(self, cmd, propose_default_path(cmd))
301 @botmodule_triggers << cmd unless opts.fetch(:hidden, false)
304 # Default usage method provided as a utility for simple plugins. The
305 # MessageMapper uses 'usage' as its default fallback method.
307 def usage(m, params = {})
308 m.reply(_("incorrect usage, ask for help using '%{command}'") % {:command => "#{@bot.nick}: help #{m.plugin}"})
313 # A CoreBotModule is a BotModule that provides core functionality.
315 # This class should not be used by user plugins, as it's reserved for system
316 # plugins such as the ones that handle authentication, configuration and basic
319 class CoreBotModule < BotModule
325 # A Plugin is a BotModule that provides additional functionality.
327 # A user-defined plugin should subclass this, and then define any of the
328 # methods described in the documentation for BotModule to handle interaction
331 class Plugin < BotModule
337 # Singleton to manage multiple plugins and delegate messages to them for
339 class PluginManagerClass
342 attr_reader :botmodules
345 # This is the list of patterns commonly delegated to plugins.
346 # A fast delegation lookup is enabled for them.
347 DEFAULT_DELEGATE_PATTERNS = %r{^(?:
349 listen|ctcp_listen|privmsg|unreplied|
351 save|cleanup|flush_registry|
357 :CoreBotModule => [],
361 @names_hash = Hash.new
362 @commandmappers = Hash.new
364 @delegate_list = Hash.new { |h, k|
377 ret = self.to_s[0..-2]
378 ret << ' corebotmodules='
379 ret << @botmodules[:CoreBotModule].map { |m|
383 ret << @botmodules[:Plugin].map { |m|
389 # Reset lists of botmodules
390 def reset_botmodule_lists
391 @botmodules[:CoreBotModule].clear
392 @botmodules[:Plugin].clear
394 @commandmappers.clear
396 @failures_shown = false
399 # Associate with bot _bot_
400 def bot_associate(bot)
401 reset_botmodule_lists
405 # Returns the botmodule with the given _name_
407 @names_hash[name.to_sym]
410 # Returns +true+ if _cmd_ has already been registered as a command
411 def who_handles?(cmd)
412 return nil unless @commandmappers.has_key?(cmd.to_sym)
413 return @commandmappers[cmd.to_sym][:botmodule]
416 # Registers botmodule _botmodule_ with command _cmd_ and command path _auth_path_
417 def register(botmodule, cmd, auth_path)
418 raise TypeError, "First argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
419 @commandmappers[cmd.to_sym] = {:botmodule => botmodule, :auth => auth_path}
422 # Registers botmodule _botmodule_ with map _map_. This adds the map to the #maps hash
423 # which has three keys:
425 # botmodule:: the associated botmodule
426 # auth:: an array of auth keys checked by the map; the first is the full_auth_path of the map
427 # map:: the actual MessageTemplate object
430 def register_map(botmodule, map)
431 raise TypeError, "First argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
432 @maps[map.template] = { :botmodule => botmodule, :auth => [map.options[:full_auth_path]], :map => map }
435 def add_botmodule(botmodule)
436 raise TypeError, "Argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
437 kl = botmodule.botmodule_class
438 if @names_hash.has_key?(botmodule.to_sym)
439 case self[botmodule].botmodule_class
441 raise "#{kl} #{botmodule} already registered!"
443 raise "#{self[botmodule].botmodule_class} #{botmodule} already registered, cannot re-register as #{kl}"
446 @botmodules[kl] << botmodule
447 @names_hash[botmodule.to_sym] = botmodule
450 # Returns an array of the loaded plugins
452 @botmodules[:CoreBotModule]
455 # Returns an array of the loaded plugins
460 # Returns a hash of the registered message prefixes and associated
466 # Makes a string of error _err_ by adding text _str_
467 def report_error(str, err)
468 ([str, err.inspect] + err.backtrace).join("\n")
471 # This method is the one that actually loads a module from the
474 # _desc_ is a simple description of what we are loading (plugin/botmodule/whatever)
476 # It returns the Symbol :loaded on success, and an Exception
479 def load_botmodule_file(fname, desc=nil)
480 # create a new, anonymous module to "house" the plugin
481 # the idea here is to prevent namespace pollution. perhaps there
483 plugin_module = Module.new
485 desc = desc.to_s + " " if desc
488 plugin_string = IO.readlines(fname).join("")
489 debug "loading #{desc}#{fname}"
490 plugin_module.module_eval(plugin_string, fname)
492 rescue Exception => err
493 # rescue TimeoutError, StandardError, NameError, LoadError, SyntaxError => err
494 error report_error("#{desc}#{fname} load failed", err)
495 bt = err.backtrace.select { |line|
496 line.match(/^(\(eval\)|#{fname}):\d+/)
499 el.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
503 msg = err.to_str.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
506 newerr = err.class.new(msg)
507 newerr.set_backtrace(bt)
511 private :load_botmodule_file
513 # add one or more directories to the list of directories to
514 # load botmodules from
516 # TODO find a way to specify necessary plugins which _must_ be loaded
518 def add_botmodule_dir(*dirlist)
520 debug "Botmodule loading path: #{@dirs.join(', ')}"
523 def clear_botmodule_dirs
525 debug "Botmodule loading path cleared"
528 # load plugins from pre-assigned list of directories
536 @bot.config['plugins.blacklist'].each { |p|
538 processed[pn.intern] = :blacklisted
543 if(FileTest.directory?(dir))
547 next if(file =~ /^\./)
549 if processed.has_key?(file.intern)
550 @ignored << {:name => file, :dir => dir, :reason => processed[file.intern]}
554 if(file =~ /^(.+\.rb)\.disabled$/)
555 # GB: Do we want to do this? This means that a disabled plugin in a directory
556 # will disable in all subsequent directories. This was probably meant
557 # to be used before plugins.blacklist was implemented, so I think
558 # we don't need this anymore
559 processed[$1.intern] = :disabled
560 @ignored << {:name => $1, :dir => dir, :reason => processed[$1.intern]}
564 next unless(file =~ /\.rb$/)
566 did_it = load_botmodule_file("#{dir}/#{file}", "plugin")
569 processed[file.intern] = did_it
571 @failed << { :name => file, :dir => dir, :reason => did_it }
577 debug "finished loading plugins: #{status(true)}"
578 (core_modules + plugins).each { |p|
579 p.methods.grep(DEFAULT_DELEGATE_PATTERNS).each { |m|
580 @delegate_list[m.intern] << p
585 # call the save method for each active plugin
587 delegate 'flush_registry'
591 # call the cleanup method for each active plugin
594 reset_botmodule_lists
597 # drop all plugins and rescan plugins on disk
598 # calls save and cleanup for each plugin before dropping them
605 def status(short=false)
607 if self.core_length > 0
609 output << n_("%{count} core module loaded", "%{count} core modules loaded",
610 self.core_length) % {:count => self.core_length}
612 output << n_("%{count} core module: %{list}",
613 "%{count} core modules: %{list}", self.core_length) %
614 { :count => self.core_length,
615 :list => core_modules.collect{ |p| p.name}.sort.join(", ") }
618 output << _("no core botmodules loaded")
620 # Active plugins first
623 output << n_("%{count} plugin loaded", "%{count} plugins loaded",
624 self.length) % {:count => self.length}
626 output << n_("%{count} plugin: %{list}",
627 "%{count} plugins: %{list}", self.length) %
628 { :count => self.length,
629 :list => plugins.collect{ |p| p.name}.sort.join(", ") }
632 output << "no plugins active"
634 # Ignored plugins next
635 unless @ignored.empty? or @failures_shown
637 output << n_("%{highlight}%{count} plugin ignored%{highlight}",
638 "%{highlight}%{count} plugins ignored%{highlight}",
640 { :count => @ignored.length, :highlight => Underline }
642 output << n_("%{highlight}%{count} plugin ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
643 "%{highlight}%{count} plugins ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
645 { :count => @ignored.length, :highlight => Underline,
646 :bold => Bold, :command => "help ignored plugins"}
649 # Failed plugins next
650 unless @failed.empty? or @failures_shown
652 output << n_("%{highlight}%{count} plugin failed to load%{highlight}",
653 "%{highlight}%{count} plugins failed to load%{highlight}",
655 { :count => @failed.length, :highlight => Reverse }
657 output << n_("%{highlight}%{count} plugin failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
658 "%{highlight}%{count} plugins failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
660 { :count => @failed.length, :highlight => Reverse,
661 :bold => Bold, :command => "help failed plugins"}
667 # return list of help topics (plugin names)
670 @failures_shown = true
682 # return help for +topic+ (call associated plugin's help method)
685 when /fail(?:ed)?\s*plugins?.*(trace(?:back)?s?)?/
686 # debug "Failures: #{@failed.inspect}"
687 return _("no plugins failed to load") if @failed.empty?
688 return @failed.collect { |p|
689 _('%{highlight}%{plugin}%{highlight} in %{dir} failed with error %{exception}: %{reason}') % {
690 :highlight => Bold, :plugin => p[:name], :dir => p[:dir],
691 :exception => p[:reason].class, :reason => p[:reason],
692 } + if $1 && !p[:reason].backtrace.empty?
693 _('at %{backtrace}') % {:backtrace => p[:reason].backtrace.join(', ')}
698 when /ignored?\s*plugins?/
699 return _('no plugins were ignored') if @ignored.empty?
703 reason = p[:loaded] ? _('overruled by previous') : _(p[:reason].to_s)
704 ((tmp[p[:dir]] ||= Hash.new)[reason] ||= Array.new).push(p[:name])
707 return tmp.map do |dir, reasons|
708 # FIXME get rid of these string concatenations to make gettext easier
709 s = reasons.map { |r, list|
710 list.map { |_| _.sub(/\.rb$/, '') }.join(', ') + " (#{r})"
714 when /^(\S+)\s*(.*)$/
718 # Let's see if we can match a plugin by the given name
719 (core_modules + plugins).each { |p|
720 next unless p.name == key
722 return p.help(key, params)
723 rescue Exception => err
724 #rescue TimeoutError, StandardError, NameError, SyntaxError => err
725 error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
729 # Nope, let's see if it's a command, and ask for help at the corresponding botmodule
731 if commands.has_key?(k)
732 p = commands[k][:botmodule]
734 return p.help(key, params)
735 rescue Exception => err
736 #rescue TimeoutError, StandardError, NameError, SyntaxError => err
737 error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
744 # see if each plugin handles +method+, and if so, call it, passing
745 # +message+ as a parameter
746 def delegate(method, *args)
747 # debug "Delegating #{method.inspect}"
749 if method.match(DEFAULT_DELEGATE_PATTERNS)
750 debug "fast-delegating #{method}"
752 debug "no-one to delegate to" unless @delegate_list.has_key?(m)
753 return [] unless @delegate_list.has_key?(m)
754 @delegate_list[m].each { |p|
756 ret.push p.send(method, *args)
757 rescue Exception => err
758 raise if err.kind_of?(SystemExit)
759 error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
760 raise if err.kind_of?(BDB::Fatal)
764 debug "slow-delegating #{method}"
765 (core_modules + plugins).each { |p|
766 if(p.respond_to? method)
768 # debug "#{p.botmodule_class} #{p.name} responds"
769 ret.push p.send(method, *args)
770 rescue Exception => err
771 raise if err.kind_of?(SystemExit)
772 error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
773 raise if err.kind_of?(BDB::Fatal)
779 # debug "Finished delegating #{method.inspect}"
782 # see if we have a plugin that wants to handle this message, if so, pass
783 # it to the plugin and return true, otherwise false
785 # debug "Delegating privmsg #{m.message.inspect} from #{m.source} to #{m.replyto} with pluginkey #{m.plugin.inspect}"
786 return unless m.plugin
788 if commands.has_key?(k)
789 p = commands[k][:botmodule]
790 a = commands[k][:auth]
791 # We check here for things that don't check themselves
792 # (e.g. mapped things)
793 # debug "Checking auth ..."
794 if a.nil? || @bot.auth.allow?(a, m.source, m.replyto)
795 # debug "Checking response ..."
796 if p.respond_to?("privmsg")
798 # debug "#{p.botmodule_class} #{p.name} responds"
800 rescue Exception => err
801 raise if err.kind_of?(SystemExit)
802 error report_error("#{p.botmodule_class} #{p.name} privmsg() failed:", err)
803 raise if err.kind_of?(BDB::Fatal)
805 # debug "Successfully delegated #{m.message}"
808 # debug "#{p.botmodule_class} #{p.name} is registered, but it doesn't respond to privmsg()"
811 # debug "#{p.botmodule_class} #{p.name} is registered, but #{m.source} isn't allowed to call #{m.plugin.inspect} on #{m.replyto}"
814 # debug "Finished delegating privmsg with key #{m.plugin.inspect}" + ( pl.empty? ? "" : " to #{pl.values.first[:botmodule].botmodule_class}s" )
816 # debug "Finished delegating privmsg with key #{m.plugin.inspect}"
820 # Returns the only PluginManagerClass instance
822 return PluginManagerClass.instance