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 base class for all rbot plugins
17 certain methods will be called if they are provided, if you define one of
18 the following methods, it will be called as appropriate:
20 map(template, options)::
21 map!(template, options)::
22 map is the new, cleaner way to respond to specific message formats
23 without littering your plugin code with regexps. The difference
24 between map and map! is that map! will not register the new command
25 as an alternative name for the plugin.
29 plugin.map 'karmastats', :action => 'karma_stats'
31 # while in the plugin...
32 def karma_stats(m, params)
36 # the default action is the first component
39 # attributes can be pulled out of the match string
40 plugin.map 'karma for :key'
41 plugin.map 'karma :key'
43 # while in the plugin...
46 m.reply 'karma for #{item}'
49 # you can setup defaults, to make parameters optional
50 plugin.map 'karma :key', :defaults => {:key => 'defaultvalue'}
52 # the default auth check is also against the first component
53 # but that can be changed
54 plugin.map 'karmastats', :auth => 'karma'
56 # maps can be restricted to public or private message:
57 plugin.map 'karmastats', :private false,
58 plugin.map 'karmastats', :public false,
62 Called for all messages of any type. To
63 differentiate them, use message.kind_of? It'll be
64 either a PrivMessage, NoticeMessage, KickMessage,
65 QuitMessage, PartMessage, JoinMessage, NickMessage,
68 ctcp_listen(UserMessage)::
69 Called for all messages that contain a CTCP command.
70 Use message.ctcp to get the CTCP command, and
71 message.message to get the parameter string. To reply,
72 use message.ctcp_reply, which sends a private NOTICE
75 privmsg(PrivMessage)::
76 Called for a PRIVMSG if the first word matches one
77 the plugin register()d for. Use m.plugin to get
78 that word and m.params for the rest of the message,
81 unreplied(PrivMessage)::
82 Called for a PRIVMSG which has not been replied to.
85 Called when a user (or the bot) is kicked from a
86 channel the bot is in.
89 Called when a user (or the bot) joins a channel
92 Called when a user (or the bot) parts a channel
95 Called when a user (or the bot) quits IRC
98 Called when a user (or the bot) changes Nick
100 Called when a user (or the bot) changes a channel
103 connect():: Called when a server is joined successfully, but
104 before autojoin channels are joined (no params)
106 set_language(String)::
107 Called when the user sets a new language
108 whose name is the given String
110 save:: Called when you are required to save your plugin's
111 state, if you maintain data between sessions
113 cleanup:: called before your plugin is "unloaded", prior to a
114 plugin reload or bot quit - close any open
115 files/connections or flush caches here
119 attr_reader :bot # the associated bot
121 # initialise your bot module. Always call super if you override this method,
122 # as important variables are set up for you
124 @manager = Plugins::manager
127 @botmodule_triggers = Array.new
129 @handler = MessageMapper.new(self)
130 @registry = BotRegistryAccessor.new(@bot, self.class.to_s.gsub(/^.*::/, ""))
132 @manager.add_botmodule(self)
133 if self.respond_to?('set_language')
134 self.set_language(@bot.lang.language)
143 # debug "Flushing #{@registry}"
148 # debug "Closing #{@registry}"
156 def call_event(ev, *args)
157 @bot.plugins.delegate('event_' + ev.to_s.gsub(/[^\w\?!]+/, '_'), *args)
161 @handler.map(self, *args)
163 name = @handler.last.items[0]
164 self.register name, :auth => nil
165 unless self.respond_to?('privmsg')
173 @handler.map(self, *args)
175 name = @handler.last.items[0]
176 self.register name, :auth => nil, :hidden => true
177 unless self.respond_to?('privmsg')
184 # Sets the default auth for command path _cmd_ to _val_ on channel _chan_:
185 # usually _chan_ is either "*" for everywhere, public and private (in which
186 # case it can be omitted) or "?" for private communications
188 def default_auth(cmd, val, chan="*")
195 Auth::defaultbotuser.set_default_permission(propose_default_path(c), val)
198 # Gets the default command path which would be given to command _cmd_
199 def propose_default_path(cmd)
200 [name, cmd].compact.join("::")
203 # return an identifier for this plugin, defaults to a list of the message
204 # prefixes handled (used for error messages etc)
206 self.class.to_s.downcase.sub(/^#<module:.*?>::/,"").sub(/(plugin|module)?$/,"")
219 # return a help string for your module. for complex modules, you may wish
220 # to break your help into topics, and return a list of available topics if
221 # +topic+ is nil. +plugin+ is passed containing the matching prefix for
222 # this message - if your plugin handles multiple prefixes, make sure you
223 # return the correct help for the prefix requested
224 def help(plugin, topic)
228 # register the plugin as a handler for messages prefixed +name+
229 # this can be called multiple times for a plugin to handle multiple
231 def register(cmd, opts={})
232 raise ArgumentError, "Second argument must be a hash!" unless opts.kind_of?(Hash)
233 who = @manager.who_handles?(cmd)
235 raise "Command #{cmd} is already handled by #{who.botmodule_class} #{who}" if who != self
238 if opts.has_key?(:auth)
239 @manager.register(self, cmd, opts[:auth])
241 @manager.register(self, cmd, propose_default_path(cmd))
243 @botmodule_triggers << cmd unless opts.fetch(:hidden, false)
246 # default usage method provided as a utility for simple plugins. The
247 # MessageMapper uses 'usage' as its default fallback method.
248 def usage(m, params = {})
249 m.reply(_("incorrect usage, ask for help using '%{command}'") % {:command => "#{@bot.nick}: help #{m.plugin}"})
254 class CoreBotModule < BotModule
260 class Plugin < BotModule
266 # Singleton to manage multiple plugins and delegate messages to them for
268 class PluginManagerClass
271 attr_reader :botmodules
273 # This is the list of patterns commonly delegated to plugins.
274 # A fast delegation lookup is enabled for them.
275 DEFAULT_DELEGATE_PATTERNS = %r{^(?:
277 listen|ctcp_listen|privmsg|unreplied|
279 save|cleanup|flush_registry|
285 :CoreBotModule => [],
289 @names_hash = Hash.new
290 @commandmappers = Hash.new
291 @delegate_list = Hash.new { |h, k|
303 # Reset lists of botmodules
304 def reset_botmodule_lists
305 @botmodules[:CoreBotModule].clear
306 @botmodules[:Plugin].clear
308 @commandmappers.clear
309 @failures_shown = false
312 # Associate with bot _bot_
313 def bot_associate(bot)
314 reset_botmodule_lists
318 # Returns the botmodule with the given _name_
320 @names_hash[name.to_sym]
323 # Returns +true+ if _cmd_ has already been registered as a command
324 def who_handles?(cmd)
325 return nil unless @commandmappers.has_key?(cmd.to_sym)
326 return @commandmappers[cmd.to_sym][:botmodule]
329 # Registers botmodule _botmodule_ with command _cmd_ and command path _auth_path_
330 def register(botmodule, cmd, auth_path)
331 raise TypeError, "First argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
332 @commandmappers[cmd.to_sym] = {:botmodule => botmodule, :auth => auth_path}
335 def add_botmodule(botmodule)
336 raise TypeError, "Argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
337 kl = botmodule.botmodule_class
338 if @names_hash.has_key?(botmodule.to_sym)
339 case self[botmodule].botmodule_class
341 raise "#{kl} #{botmodule} already registered!"
343 raise "#{self[botmodule].botmodule_class} #{botmodule} already registered, cannot re-register as #{kl}"
346 @botmodules[kl] << botmodule
347 @names_hash[botmodule.to_sym] = botmodule
350 # Returns an array of the loaded plugins
352 @botmodules[:CoreBotModule]
355 # Returns an array of the loaded plugins
360 # Returns a hash of the registered message prefixes and associated
366 # Makes a string of error _err_ by adding text _str_
367 def report_error(str, err)
368 ([str, err.inspect] + err.backtrace).join("\n")
371 # This method is the one that actually loads a module from the
374 # _desc_ is a simple description of what we are loading (plugin/botmodule/whatever)
376 # It returns the Symbol :loaded on success, and an Exception
379 def load_botmodule_file(fname, desc=nil)
380 # create a new, anonymous module to "house" the plugin
381 # the idea here is to prevent namespace pollution. perhaps there
383 plugin_module = Module.new
385 desc = desc.to_s + " " if desc
388 plugin_string = IO.readlines(fname).join("")
389 debug "loading #{desc}#{fname}"
390 plugin_module.module_eval(plugin_string, fname)
392 rescue Exception => err
393 # rescue TimeoutError, StandardError, NameError, LoadError, SyntaxError => err
394 error report_error("#{desc}#{fname} load failed", err)
395 bt = err.backtrace.select { |line|
396 line.match(/^(\(eval\)|#{fname}):\d+/)
399 el.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
403 msg = err.to_str.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
406 newerr = err.class.new(msg)
407 newerr.set_backtrace(bt)
411 private :load_botmodule_file
413 # add one or more directories to the list of directories to
414 # load botmodules from
416 # TODO find a way to specify necessary plugins which _must_ be loaded
418 def add_botmodule_dir(*dirlist)
420 debug "Botmodule loading path: #{@dirs.join(', ')}"
423 def clear_botmodule_dirs
425 debug "Botmodule loading path cleared"
428 # load plugins from pre-assigned list of directories
436 @bot.config['plugins.blacklist'].each { |p|
438 processed[pn.intern] = :blacklisted
443 if(FileTest.directory?(dir))
447 next if(file =~ /^\./)
449 if processed.has_key?(file.intern)
450 @ignored << {:name => file, :dir => dir, :reason => processed[file.intern]}
454 if(file =~ /^(.+\.rb)\.disabled$/)
455 # GB: Do we want to do this? This means that a disabled plugin in a directory
456 # will disable in all subsequent directories. This was probably meant
457 # to be used before plugins.blacklist was implemented, so I think
458 # we don't need this anymore
459 processed[$1.intern] = :disabled
460 @ignored << {:name => $1, :dir => dir, :reason => processed[$1.intern]}
464 next unless(file =~ /\.rb$/)
466 did_it = load_botmodule_file("#{dir}/#{file}", "plugin")
469 processed[file.intern] = did_it
471 @failed << { :name => file, :dir => dir, :reason => did_it }
477 debug "finished loading plugins: #{status(true)}"
478 (core_modules + plugins).each { |p|
479 p.methods.grep(DEFAULT_DELEGATE_PATTERNS).each { |m|
480 @delegate_list[m.intern] << p
485 # call the save method for each active plugin
487 delegate 'flush_registry'
491 # call the cleanup method for each active plugin
494 reset_botmodule_lists
497 # drop all plugins and rescan plugins on disk
498 # calls save and cleanup for each plugin before dropping them
505 def status(short=false)
507 if self.core_length > 0
509 output << n_("%{count} core module loaded", "%{count} core modules loaded",
510 self.core_length) % {:count => self.core_length}
512 output << n_("%{count} core module: %{list}",
513 "%{count} core modules: %{list}", self.core_length) %
514 { :count => self.core_length,
515 :list => core_modules.collect{ |p| p.name}.sort.join(", ") }
518 output << _("no core botmodules loaded")
520 # Active plugins first
523 output << n_("%{count} plugin loaded", "%{count} plugins loaded",
524 self.length) % {:count => self.length}
526 output << n_("%{count} plugin: %{list}",
527 "%{count} plugins: %{list}", self.length) %
528 { :count => self.length,
529 :list => plugins.collect{ |p| p.name}.sort.join(", ") }
532 output << "no plugins active"
534 # Ignored plugins next
535 unless @ignored.empty? or @failures_shown
537 output << n_("%{highlight}%{count} plugin ignored%{highlight}",
538 "%{highlight}%{count} plugins ignored%{highlight}",
540 { :count => @ignored.length, :highlight => Underline }
542 output << n_("%{highlight}%{count} plugin ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
543 "%{highlight}%{count} plugins ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
545 { :count => @ignored.length, :highlight => Underline,
546 :bold => Bold, :command => "help ignored plugins"}
549 # Failed plugins next
550 unless @failed.empty? or @failures_shown
552 output << n_("%{highlight}%{count} plugin failed to load%{highlight}",
553 "%{highlight}%{count} plugins failed to load%{highlight}",
555 { :count => @failed.length, :highlight => Reverse }
557 output << n_("%{highlight}%{count} plugin failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
558 "%{highlight}%{count} plugins failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
560 { :count => @failed.length, :highlight => Reverse,
561 :bold => Bold, :command => "help failed plugins"}
567 # return list of help topics (plugin names)
570 @failures_shown = true
582 # return help for +topic+ (call associated plugin's help method)
585 when /fail(?:ed)?\s*plugins?.*(trace(?:back)?s?)?/
586 # debug "Failures: #{@failed.inspect}"
587 return _("no plugins failed to load") if @failed.empty?
588 return @failed.collect { |p|
589 _('%{highlight}%{plugin}%{highlight} in %{dir} failed with error %{exception}: %{reason}') % {
590 :highlight => Bold, :plugin => p[:name], :dir => p[:dir],
591 :exception => p[:reason].class, :reason => p[:reason],
592 } + if $1 && !p[:reason].backtrace.empty?
593 _('at %{backtrace}') % {:backtrace => p[:reason].backtrace.join(', ')}
598 when /ignored?\s*plugins?/
599 return _('no plugins were ignored') if @ignored.empty?
603 reason = p[:loaded] ? _('overruled by previous') : _(p[:reason].to_s)
604 ((tmp[p[:dir]] ||= Hash.new)[reason] ||= Array.new).push(p[:name])
607 return tmp.map do |dir, reasons|
608 # FIXME get rid of these string concatenations to make gettext easier
609 s = reasons.map { |r, list|
610 list.map { |_| _.sub(/\.rb$/, '') }.join(', ') + " (#{r})"
614 when /^(\S+)\s*(.*)$/
618 # Let's see if we can match a plugin by the given name
619 (core_modules + plugins).each { |p|
620 next unless p.name == key
622 return p.help(key, params)
623 rescue Exception => err
624 #rescue TimeoutError, StandardError, NameError, SyntaxError => err
625 error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
629 # Nope, let's see if it's a command, and ask for help at the corresponding botmodule
631 if commands.has_key?(k)
632 p = commands[k][:botmodule]
634 return p.help(key, params)
635 rescue Exception => err
636 #rescue TimeoutError, StandardError, NameError, SyntaxError => err
637 error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
644 # see if each plugin handles +method+, and if so, call it, passing
645 # +message+ as a parameter
646 def delegate(method, *args)
647 # debug "Delegating #{method.inspect}"
649 if method.match(DEFAULT_DELEGATE_PATTERNS)
650 debug "fast-delegating #{method}"
652 debug "no-one to delegate to" unless @delegate_list.has_key?(m)
653 return [] unless @delegate_list.has_key?(m)
654 @delegate_list[m].each { |p|
656 ret.push p.send(method, *args)
657 rescue Exception => err
658 raise if err.kind_of?(SystemExit)
659 error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
660 raise if err.kind_of?(BDB::Fatal)
664 debug "slow-delegating #{method}"
665 (core_modules + plugins).each { |p|
666 if(p.respond_to? method)
668 # debug "#{p.botmodule_class} #{p.name} responds"
669 ret.push p.send(method, *args)
670 rescue Exception => err
671 raise if err.kind_of?(SystemExit)
672 error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
673 raise if err.kind_of?(BDB::Fatal)
679 # debug "Finished delegating #{method.inspect}"
682 # see if we have a plugin that wants to handle this message, if so, pass
683 # it to the plugin and return true, otherwise false
685 # debug "Delegating privmsg #{m.message.inspect} from #{m.source} to #{m.replyto} with pluginkey #{m.plugin.inspect}"
686 return unless m.plugin
688 if commands.has_key?(k)
689 p = commands[k][:botmodule]
690 a = commands[k][:auth]
691 # We check here for things that don't check themselves
692 # (e.g. mapped things)
693 # debug "Checking auth ..."
694 if a.nil? || @bot.auth.allow?(a, m.source, m.replyto)
695 # debug "Checking response ..."
696 if p.respond_to?("privmsg")
698 # debug "#{p.botmodule_class} #{p.name} responds"
700 rescue Exception => err
701 raise if err.kind_of?(SystemExit)
702 error report_error("#{p.botmodule_class} #{p.name} privmsg() failed:", err)
703 raise if err.kind_of?(BDB::Fatal)
705 # debug "Successfully delegated #{m.message}"
708 # debug "#{p.botmodule_class} #{p.name} is registered, but it doesn't respond to privmsg()"
711 # debug "#{p.botmodule_class} #{p.name} is registered, but #{m.source} isn't allowed to call #{m.plugin.inspect} on #{m.replyto}"
714 # debug "Finished delegating privmsg with key #{m.plugin.inspect}" + ( pl.empty? ? "" : " to #{pl.values.first[:botmodule].botmodule_class}s" )
716 # debug "Finished delegating privmsg with key #{m.plugin.inspect}"
720 # Returns the only PluginManagerClass instance
722 return PluginManagerClass.instance