4 # :title: rbot plugin management
9 BotConfig.register BotConfigArrayValue.new('plugins.blacklist',
10 :default => [], :wizard => false, :requires_rescan => true,
11 :desc => "Plugins that should not be loaded")
13 require 'rbot/messagemapper'
16 BotModule is the base class for the modules that enhance the rbot
17 functionality. Rather than subclassing BotModule, however, one should
18 subclass either CoreBotModule (reserved for system modules) or Plugin
21 A BotModule interacts with Irc events by defining one or more of the following
22 methods, which get called as appropriate when the corresponding Irc event
25 map(template, options)::
26 map!(template, options)::
27 map is the new, cleaner way to respond to specific message formats without
28 littering your plugin code with regexps, and should be used instead of
29 #register() and #privmsg() (see below) when possible.
31 The difference between map and map! is that map! will not register the new
32 command as an alternative name for the plugin.
36 plugin.map 'karmastats', :action => 'karma_stats'
38 # while in the plugin...
39 def karma_stats(m, params)
43 # the default action is the first component
46 # attributes can be pulled out of the match string
47 plugin.map 'karma for :key'
48 plugin.map 'karma :key'
50 # while in the plugin...
53 m.reply 'karma for #{item}'
56 # you can setup defaults, to make parameters optional
57 plugin.map 'karma :key', :defaults => {:key => 'defaultvalue'}
59 # the default auth check is also against the first component
60 # but that can be changed
61 plugin.map 'karmastats', :auth => 'karma'
63 # maps can be restricted to public or private message:
64 plugin.map 'karmastats', :private => false
65 plugin.map 'karmastats', :public => false
67 See MessageMapper#map for more information on the template format and the
71 Called for all messages of any type. To
72 differentiate them, use message.kind_of? It'll be
73 either a PrivMessage, NoticeMessage, KickMessage,
74 QuitMessage, PartMessage, JoinMessage, NickMessage,
77 ctcp_listen(UserMessage)::
78 Called for all messages that contain a CTCP command.
79 Use message.ctcp to get the CTCP command, and
80 message.message to get the parameter string. To reply,
81 use message.ctcp_reply, which sends a private NOTICE
84 privmsg(PrivMessage)::
85 Called for a PRIVMSG if the first word matches one
86 the plugin #register()ed for. Use m.plugin to get
87 that word and m.params for the rest of the message,
90 unreplied(PrivMessage)::
91 Called for a PRIVMSG which has not been replied to.
94 Called when a user (or the bot) is kicked from a
95 channel the bot is in.
98 Called when a user (or the bot) joins a channel
101 Called when a user (or the bot) parts a channel
104 Called when a user (or the bot) quits IRC
107 Called when a user (or the bot) changes Nick
108 topic(TopicMessage)::
109 Called when a user (or the bot) changes a channel
112 connect:: Called when a server is joined successfully, but
113 before autojoin channels are joined (no params)
115 set_language(String)::
116 Called when the user sets a new language
117 whose name is the given String
119 save:: Called when you are required to save your plugin's
120 state, if you maintain data between sessions
122 cleanup:: called before your plugin is "unloaded", prior to a
123 plugin reload or bot quit - close any open
124 files/connections or flush caches here
128 attr_reader :bot # the associated bot
130 # Initialise your bot module. Always call super if you override this method,
131 # as important variables are set up for you:
136 # the botmodule's registry, which can be used to store permanent data
137 # (see BotRegistryAccessor for additional documentation)
139 # Other instance variables which are defined and should not be overwritten
140 # byt the user, but aren't usually accessed directly, are:
143 # the plugins manager instance
144 # @botmodule_triggers::
145 # an Array of words this plugin #register()ed itself for
147 # the MessageMapper that handles this plugin's maps
150 @manager = Plugins::manager
153 @botmodule_triggers = Array.new
155 @handler = MessageMapper.new(self)
156 @registry = BotRegistryAccessor.new(@bot, self.class.to_s.gsub(/^.*::/, ""))
158 @manager.add_botmodule(self)
159 if self.respond_to?('set_language')
160 self.set_language(@bot.lang.language)
164 # Returns the symbol :BotModule
169 # Method called to flush the registry, thus ensuring that the botmodule's permanent
170 # data is committed to disk
173 # debug "Flushing #{@registry}"
177 # Method called to cleanup before the plugin is unloaded. If you overload
178 # this method to handle additional cleanup tasks, remember to call super()
179 # so that the default cleanup actions are taken care of as well.
182 # debug "Closing #{@registry}"
186 # Handle an Irc::PrivMessage for which this BotModule has a map. The method
187 # is called automatically and there is usually no need to call it
194 # Signal to other BotModules that an even happened.
196 def call_event(ev, *args)
197 @bot.plugins.delegate('event_' + ev.to_s.gsub(/[^\w\?!]+/, '_'), *args)
200 # call-seq: map(template, options)
202 # This is the preferred way to register the BotModule so that it
203 # responds to appropriately-formed messages on Irc.
206 @handler.map(self, *args)
208 name = @handler.last.items[0]
209 self.register name, :auth => nil
210 unless self.respond_to?('privmsg')
211 def self.privmsg(m) #:nodoc:
217 # call-seq: map!(template, options)
219 # This is the same as map but doesn't register the new command
220 # as an alternative name for the plugin.
223 @handler.map(self, *args)
225 name = @handler.last.items[0]
226 self.register name, :auth => nil, :hidden => true
227 unless self.respond_to?('privmsg')
228 def self.privmsg(m) #:nodoc:
234 # Sets the default auth for command path _cmd_ to _val_ on channel _chan_:
235 # usually _chan_ is either "*" for everywhere, public and private (in which
236 # case it can be omitted) or "?" for private communications
238 def default_auth(cmd, val, chan="*")
245 Auth::defaultbotuser.set_default_permission(propose_default_path(c), val)
248 # Gets the default command path which would be given to command _cmd_
249 def propose_default_path(cmd)
250 [name, cmd].compact.join("::")
253 # Return an identifier for this plugin, defaults to a list of the message
254 # prefixes handled (used for error messages etc)
256 self.class.to_s.downcase.sub(/^#<module:.*?>::/,"").sub(/(plugin|module)?$/,"")
269 # Return a help string for your module. For complex modules, you may wish
270 # to break your help into topics, and return a list of available topics if
271 # +topic+ is nil. +plugin+ is passed containing the matching prefix for
272 # this message - if your plugin handles multiple prefixes, make sure you
273 # return the correct help for the prefix requested
274 def help(plugin, topic)
278 # Register the plugin as a handler for messages prefixed _cmd_.
280 # This can be called multiple times for a plugin to handle multiple message
283 # This command is now superceded by the #map() command, which should be used
284 # instead whenever possible.
286 def register(cmd, opts={})
287 raise ArgumentError, "Second argument must be a hash!" unless opts.kind_of?(Hash)
288 who = @manager.who_handles?(cmd)
290 raise "Command #{cmd} is already handled by #{who.botmodule_class} #{who}" if who != self
293 if opts.has_key?(:auth)
294 @manager.register(self, cmd, opts[:auth])
296 @manager.register(self, cmd, propose_default_path(cmd))
298 @botmodule_triggers << cmd unless opts.fetch(:hidden, false)
301 # Default usage method provided as a utility for simple plugins. The
302 # MessageMapper uses 'usage' as its default fallback method.
304 def usage(m, params = {})
305 m.reply(_("incorrect usage, ask for help using '%{command}'") % {:command => "#{@bot.nick}: help #{m.plugin}"})
310 # A CoreBotModule is a BotModule that provides core functionality.
312 # This class should not be used by user plugins, as it's reserved for system
313 # plugins such as the ones that handle authentication, configuration and basic
316 class CoreBotModule < BotModule
322 # A Plugin is a BotModule that provides additional functionality.
324 # A user-defined plugin should subclass this, and then define any of the
325 # methods described in the documentation for BotModule to handle interaction
328 class Plugin < BotModule
334 # Singleton to manage multiple plugins and delegate messages to them for
336 class PluginManagerClass
339 attr_reader :botmodules
341 # This is the list of patterns commonly delegated to plugins.
342 # A fast delegation lookup is enabled for them.
343 DEFAULT_DELEGATE_PATTERNS = %r{^(?:
345 listen|ctcp_listen|privmsg|unreplied|
347 save|cleanup|flush_registry|
353 :CoreBotModule => [],
357 @names_hash = Hash.new
358 @commandmappers = Hash.new
359 @delegate_list = Hash.new { |h, k|
371 # Reset lists of botmodules
372 def reset_botmodule_lists
373 @botmodules[:CoreBotModule].clear
374 @botmodules[:Plugin].clear
376 @commandmappers.clear
377 @failures_shown = false
380 # Associate with bot _bot_
381 def bot_associate(bot)
382 reset_botmodule_lists
386 # Returns the botmodule with the given _name_
388 @names_hash[name.to_sym]
391 # Returns +true+ if _cmd_ has already been registered as a command
392 def who_handles?(cmd)
393 return nil unless @commandmappers.has_key?(cmd.to_sym)
394 return @commandmappers[cmd.to_sym][:botmodule]
397 # Registers botmodule _botmodule_ with command _cmd_ and command path _auth_path_
398 def register(botmodule, cmd, auth_path)
399 raise TypeError, "First argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
400 @commandmappers[cmd.to_sym] = {:botmodule => botmodule, :auth => auth_path}
403 def add_botmodule(botmodule)
404 raise TypeError, "Argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
405 kl = botmodule.botmodule_class
406 if @names_hash.has_key?(botmodule.to_sym)
407 case self[botmodule].botmodule_class
409 raise "#{kl} #{botmodule} already registered!"
411 raise "#{self[botmodule].botmodule_class} #{botmodule} already registered, cannot re-register as #{kl}"
414 @botmodules[kl] << botmodule
415 @names_hash[botmodule.to_sym] = botmodule
418 # Returns an array of the loaded plugins
420 @botmodules[:CoreBotModule]
423 # Returns an array of the loaded plugins
428 # Returns a hash of the registered message prefixes and associated
434 # Makes a string of error _err_ by adding text _str_
435 def report_error(str, err)
436 ([str, err.inspect] + err.backtrace).join("\n")
439 # This method is the one that actually loads a module from the
442 # _desc_ is a simple description of what we are loading (plugin/botmodule/whatever)
444 # It returns the Symbol :loaded on success, and an Exception
447 def load_botmodule_file(fname, desc=nil)
448 # create a new, anonymous module to "house" the plugin
449 # the idea here is to prevent namespace pollution. perhaps there
451 plugin_module = Module.new
453 desc = desc.to_s + " " if desc
456 plugin_string = IO.readlines(fname).join("")
457 debug "loading #{desc}#{fname}"
458 plugin_module.module_eval(plugin_string, fname)
460 rescue Exception => err
461 # rescue TimeoutError, StandardError, NameError, LoadError, SyntaxError => err
462 error report_error("#{desc}#{fname} load failed", err)
463 bt = err.backtrace.select { |line|
464 line.match(/^(\(eval\)|#{fname}):\d+/)
467 el.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
471 msg = err.to_str.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
474 newerr = err.class.new(msg)
475 newerr.set_backtrace(bt)
479 private :load_botmodule_file
481 # add one or more directories to the list of directories to
482 # load botmodules from
484 # TODO find a way to specify necessary plugins which _must_ be loaded
486 def add_botmodule_dir(*dirlist)
488 debug "Botmodule loading path: #{@dirs.join(', ')}"
491 def clear_botmodule_dirs
493 debug "Botmodule loading path cleared"
496 # load plugins from pre-assigned list of directories
504 @bot.config['plugins.blacklist'].each { |p|
506 processed[pn.intern] = :blacklisted
511 if(FileTest.directory?(dir))
515 next if(file =~ /^\./)
517 if processed.has_key?(file.intern)
518 @ignored << {:name => file, :dir => dir, :reason => processed[file.intern]}
522 if(file =~ /^(.+\.rb)\.disabled$/)
523 # GB: Do we want to do this? This means that a disabled plugin in a directory
524 # will disable in all subsequent directories. This was probably meant
525 # to be used before plugins.blacklist was implemented, so I think
526 # we don't need this anymore
527 processed[$1.intern] = :disabled
528 @ignored << {:name => $1, :dir => dir, :reason => processed[$1.intern]}
532 next unless(file =~ /\.rb$/)
534 did_it = load_botmodule_file("#{dir}/#{file}", "plugin")
537 processed[file.intern] = did_it
539 @failed << { :name => file, :dir => dir, :reason => did_it }
545 debug "finished loading plugins: #{status(true)}"
546 (core_modules + plugins).each { |p|
547 p.methods.grep(DEFAULT_DELEGATE_PATTERNS).each { |m|
548 @delegate_list[m.intern] << p
553 # call the save method for each active plugin
555 delegate 'flush_registry'
559 # call the cleanup method for each active plugin
562 reset_botmodule_lists
565 # drop all plugins and rescan plugins on disk
566 # calls save and cleanup for each plugin before dropping them
573 def status(short=false)
575 if self.core_length > 0
577 output << n_("%{count} core module loaded", "%{count} core modules loaded",
578 self.core_length) % {:count => self.core_length}
580 output << n_("%{count} core module: %{list}",
581 "%{count} core modules: %{list}", self.core_length) %
582 { :count => self.core_length,
583 :list => core_modules.collect{ |p| p.name}.sort.join(", ") }
586 output << _("no core botmodules loaded")
588 # Active plugins first
591 output << n_("%{count} plugin loaded", "%{count} plugins loaded",
592 self.length) % {:count => self.length}
594 output << n_("%{count} plugin: %{list}",
595 "%{count} plugins: %{list}", self.length) %
596 { :count => self.length,
597 :list => plugins.collect{ |p| p.name}.sort.join(", ") }
600 output << "no plugins active"
602 # Ignored plugins next
603 unless @ignored.empty? or @failures_shown
605 output << n_("%{highlight}%{count} plugin ignored%{highlight}",
606 "%{highlight}%{count} plugins ignored%{highlight}",
608 { :count => @ignored.length, :highlight => Underline }
610 output << n_("%{highlight}%{count} plugin ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
611 "%{highlight}%{count} plugins ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
613 { :count => @ignored.length, :highlight => Underline,
614 :bold => Bold, :command => "help ignored plugins"}
617 # Failed plugins next
618 unless @failed.empty? or @failures_shown
620 output << n_("%{highlight}%{count} plugin failed to load%{highlight}",
621 "%{highlight}%{count} plugins failed to load%{highlight}",
623 { :count => @failed.length, :highlight => Reverse }
625 output << n_("%{highlight}%{count} plugin failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
626 "%{highlight}%{count} plugins failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
628 { :count => @failed.length, :highlight => Reverse,
629 :bold => Bold, :command => "help failed plugins"}
635 # return list of help topics (plugin names)
638 @failures_shown = true
650 # return help for +topic+ (call associated plugin's help method)
653 when /fail(?:ed)?\s*plugins?.*(trace(?:back)?s?)?/
654 # debug "Failures: #{@failed.inspect}"
655 return _("no plugins failed to load") if @failed.empty?
656 return @failed.collect { |p|
657 _('%{highlight}%{plugin}%{highlight} in %{dir} failed with error %{exception}: %{reason}') % {
658 :highlight => Bold, :plugin => p[:name], :dir => p[:dir],
659 :exception => p[:reason].class, :reason => p[:reason],
660 } + if $1 && !p[:reason].backtrace.empty?
661 _('at %{backtrace}') % {:backtrace => p[:reason].backtrace.join(', ')}
666 when /ignored?\s*plugins?/
667 return _('no plugins were ignored') if @ignored.empty?
671 reason = p[:loaded] ? _('overruled by previous') : _(p[:reason].to_s)
672 ((tmp[p[:dir]] ||= Hash.new)[reason] ||= Array.new).push(p[:name])
675 return tmp.map do |dir, reasons|
676 # FIXME get rid of these string concatenations to make gettext easier
677 s = reasons.map { |r, list|
678 list.map { |_| _.sub(/\.rb$/, '') }.join(', ') + " (#{r})"
682 when /^(\S+)\s*(.*)$/
686 # Let's see if we can match a plugin by the given name
687 (core_modules + plugins).each { |p|
688 next unless p.name == key
690 return p.help(key, params)
691 rescue Exception => err
692 #rescue TimeoutError, StandardError, NameError, SyntaxError => err
693 error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
697 # Nope, let's see if it's a command, and ask for help at the corresponding botmodule
699 if commands.has_key?(k)
700 p = commands[k][:botmodule]
702 return p.help(key, params)
703 rescue Exception => err
704 #rescue TimeoutError, StandardError, NameError, SyntaxError => err
705 error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
712 # see if each plugin handles +method+, and if so, call it, passing
713 # +message+ as a parameter
714 def delegate(method, *args)
715 # debug "Delegating #{method.inspect}"
717 if method.match(DEFAULT_DELEGATE_PATTERNS)
718 debug "fast-delegating #{method}"
720 debug "no-one to delegate to" unless @delegate_list.has_key?(m)
721 return [] unless @delegate_list.has_key?(m)
722 @delegate_list[m].each { |p|
724 ret.push p.send(method, *args)
725 rescue Exception => err
726 raise if err.kind_of?(SystemExit)
727 error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
728 raise if err.kind_of?(BDB::Fatal)
732 debug "slow-delegating #{method}"
733 (core_modules + plugins).each { |p|
734 if(p.respond_to? method)
736 # debug "#{p.botmodule_class} #{p.name} responds"
737 ret.push p.send(method, *args)
738 rescue Exception => err
739 raise if err.kind_of?(SystemExit)
740 error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
741 raise if err.kind_of?(BDB::Fatal)
747 # debug "Finished delegating #{method.inspect}"
750 # see if we have a plugin that wants to handle this message, if so, pass
751 # it to the plugin and return true, otherwise false
753 # debug "Delegating privmsg #{m.message.inspect} from #{m.source} to #{m.replyto} with pluginkey #{m.plugin.inspect}"
754 return unless m.plugin
756 if commands.has_key?(k)
757 p = commands[k][:botmodule]
758 a = commands[k][:auth]
759 # We check here for things that don't check themselves
760 # (e.g. mapped things)
761 # debug "Checking auth ..."
762 if a.nil? || @bot.auth.allow?(a, m.source, m.replyto)
763 # debug "Checking response ..."
764 if p.respond_to?("privmsg")
766 # debug "#{p.botmodule_class} #{p.name} responds"
768 rescue Exception => err
769 raise if err.kind_of?(SystemExit)
770 error report_error("#{p.botmodule_class} #{p.name} privmsg() failed:", err)
771 raise if err.kind_of?(BDB::Fatal)
773 # debug "Successfully delegated #{m.message}"
776 # debug "#{p.botmodule_class} #{p.name} is registered, but it doesn't respond to privmsg()"
779 # debug "#{p.botmodule_class} #{p.name} is registered, but #{m.source} isn't allowed to call #{m.plugin.inspect} on #{m.replyto}"
782 # debug "Finished delegating privmsg with key #{m.plugin.inspect}" + ( pl.empty? ? "" : " to #{pl.values.first[:botmodule].botmodule_class}s" )
784 # debug "Finished delegating privmsg with key #{m.plugin.inspect}"
788 # Returns the only PluginManagerClass instance
790 return PluginManagerClass.instance