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
275 :CoreBotModule => [],
279 @names_hash = Hash.new
280 @commandmappers = Hash.new
290 # Reset lists of botmodules
291 def reset_botmodule_lists
292 @botmodules[:CoreBotModule].clear
293 @botmodules[:Plugin].clear
295 @commandmappers.clear
296 @failures_shown = false
299 # Associate with bot _bot_
300 def bot_associate(bot)
301 reset_botmodule_lists
305 # Returns the botmodule with the given _name_
307 @names_hash[name.to_sym]
310 # Returns +true+ if _cmd_ has already been registered as a command
311 def who_handles?(cmd)
312 return nil unless @commandmappers.has_key?(cmd.to_sym)
313 return @commandmappers[cmd.to_sym][:botmodule]
316 # Registers botmodule _botmodule_ with command _cmd_ and command path _auth_path_
317 def register(botmodule, cmd, auth_path)
318 raise TypeError, "First argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
319 @commandmappers[cmd.to_sym] = {:botmodule => botmodule, :auth => auth_path}
322 def add_botmodule(botmodule)
323 raise TypeError, "Argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
324 kl = botmodule.botmodule_class
325 if @names_hash.has_key?(botmodule.to_sym)
326 case self[botmodule].botmodule_class
328 raise "#{kl} #{botmodule} already registered!"
330 raise "#{self[botmodule].botmodule_class} #{botmodule} already registered, cannot re-register as #{kl}"
333 @botmodules[kl] << botmodule
334 @names_hash[botmodule.to_sym] = botmodule
337 # Returns an array of the loaded plugins
339 @botmodules[:CoreBotModule]
342 # Returns an array of the loaded plugins
347 # Returns a hash of the registered message prefixes and associated
353 # Makes a string of error _err_ by adding text _str_
354 def report_error(str, err)
355 ([str, err.inspect] + err.backtrace).join("\n")
358 # This method is the one that actually loads a module from the
361 # _desc_ is a simple description of what we are loading (plugin/botmodule/whatever)
363 # It returns the Symbol :loaded on success, and an Exception
366 def load_botmodule_file(fname, desc=nil)
367 # create a new, anonymous module to "house" the plugin
368 # the idea here is to prevent namespace pollution. perhaps there
370 plugin_module = Module.new
372 desc = desc.to_s + " " if desc
375 plugin_string = IO.readlines(fname).join("")
376 debug "loading #{desc}#{fname}"
377 plugin_module.module_eval(plugin_string, fname)
379 rescue Exception => err
380 # rescue TimeoutError, StandardError, NameError, LoadError, SyntaxError => err
381 error report_error("#{desc}#{fname} load failed", err)
382 bt = err.backtrace.select { |line|
383 line.match(/^(\(eval\)|#{fname}):\d+/)
386 el.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
390 msg = err.to_str.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
393 newerr = err.class.new(msg)
394 newerr.set_backtrace(bt)
398 private :load_botmodule_file
400 # add one or more directories to the list of directories to
401 # load botmodules from
403 # TODO find a way to specify necessary plugins which _must_ be loaded
405 def add_botmodule_dir(*dirlist)
407 debug "Botmodule loading path: #{@dirs.join(', ')}"
410 def clear_botmodule_dirs
412 debug "Botmodule loading path cleared"
415 # load plugins from pre-assigned list of directories
421 @bot.config['plugins.blacklist'].each { |p|
423 processed[pn.intern] = :blacklisted
428 if(FileTest.directory?(dir))
432 next if(file =~ /^\./)
434 if processed.has_key?(file.intern)
435 @ignored << {:name => file, :dir => dir, :reason => processed[file.intern]}
439 if(file =~ /^(.+\.rb)\.disabled$/)
440 # GB: Do we want to do this? This means that a disabled plugin in a directory
441 # will disable in all subsequent directories. This was probably meant
442 # to be used before plugins.blacklist was implemented, so I think
443 # we don't need this anymore
444 processed[$1.intern] = :disabled
445 @ignored << {:name => $1, :dir => dir, :reason => processed[$1.intern]}
449 next unless(file =~ /\.rb$/)
451 did_it = load_botmodule_file("#{dir}/#{file}", "plugin")
454 processed[file.intern] = did_it
456 @failed << { :name => file, :dir => dir, :reason => did_it }
462 debug "finished loading plugins: #{status(true)}"
465 # call the save method for each active plugin
467 delegate 'flush_registry'
471 # call the cleanup method for each active plugin
474 reset_botmodule_lists
477 # drop all plugins and rescan plugins on disk
478 # calls save and cleanup for each plugin before dropping them
485 def status(short=false)
487 if self.core_length > 0
489 output << n_("%{count} core module loaded", "%{count} core modules loaded",
490 self.core_length) % {:count => self.core_length}
492 output << n_("%{count} core module: %{list}",
493 "%{count} core modules: %{list}", self.core_length) %
494 { :count => self.core_length,
495 :list => core_modules.collect{ |p| p.name}.sort.join(", ") }
498 output << _("no core botmodules loaded")
500 # Active plugins first
503 output << n_("%{count} plugin loaded", "%{count} plugins loaded",
504 self.length) % {:count => self.length}
506 output << n_("%{count} plugin: %{list}",
507 "%{count} plugins: %{list}", self.length) %
508 { :count => self.length,
509 :list => plugins.collect{ |p| p.name}.sort.join(", ") }
512 output << "no plugins active"
514 # Ignored plugins next
515 unless @ignored.empty? or @failures_shown
517 output << n_("%{highlight}%{count} plugin ignored%{highlight}",
518 "%{highlight}%{count} plugins ignored%{highlight}",
520 { :count => @ignored.length, :highlight => Underline }
522 output << n_("%{highlight}%{count} plugin ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
523 "%{highlight}%{count} plugins ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
525 { :count => @ignored.length, :highlight => Underline,
526 :bold => Bold, :command => "help ignored plugins"}
529 # Failed plugins next
530 unless @failed.empty? or @failures_shown
532 output << n_("%{highlight}%{count} plugin failed to load%{highlight}",
533 "%{highlight}%{count} plugins failed to load%{highlight}",
535 { :count => @failed.length, :highlight => Reverse }
537 output << n_("%{highlight}%{count} plugin failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
538 "%{highlight}%{count} plugins failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
540 { :count => @failed.length, :highlight => Reverse,
541 :bold => Bold, :command => "help failed plugins"}
547 # return list of help topics (plugin names)
550 @failures_shown = true
562 # return help for +topic+ (call associated plugin's help method)
565 when /fail(?:ed)?\s*plugins?.*(trace(?:back)?s?)?/
566 # debug "Failures: #{@failed.inspect}"
567 return _("no plugins failed to load") if @failed.empty?
568 return @failed.collect { |p|
569 _('%{highlight}%{plugin}%{highlight} in %{dir} failed with error %{exception}: %{reason}') % {
570 :highlight => Bold, :plugin => p[:name], :dir => p[:dir],
571 :exception => p[:reason].class, :reason => p[:reason],
572 } + if $1 && !p[:reason].backtrace.empty?
573 _('at %{backtrace}') % {:backtrace => p[:reason].backtrace.join(', ')}
578 when /ignored?\s*plugins?/
579 return _('no plugins were ignored') if @ignored.empty?
583 reason = p[:loaded] ? _('overruled by previous') : _(p[:reason].to_s)
584 ((tmp[p[:dir]] ||= Hash.new)[reason] ||= Array.new).push(p[:name])
587 return tmp.map do |dir, reasons|
588 # FIXME get rid of these string concatenations to make gettext easier
589 s = reasons.map { |r, list|
590 list.map { |_| _.sub(/\.rb$/, '') }.join(', ') + " (#{r})"
594 when /^(\S+)\s*(.*)$/
598 # Let's see if we can match a plugin by the given name
599 (core_modules + plugins).each { |p|
600 next unless p.name == key
602 return p.help(key, params)
603 rescue Exception => err
604 #rescue TimeoutError, StandardError, NameError, SyntaxError => err
605 error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
609 # Nope, let's see if it's a command, and ask for help at the corresponding botmodule
611 if commands.has_key?(k)
612 p = commands[k][:botmodule]
614 return p.help(key, params)
615 rescue Exception => err
616 #rescue TimeoutError, StandardError, NameError, SyntaxError => err
617 error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
624 # see if each plugin handles +method+, and if so, call it, passing
625 # +message+ as a parameter
626 def delegate(method, *args)
627 # debug "Delegating #{method.inspect}"
629 (core_modules + plugins).each { |p|
630 if(p.respond_to? method)
632 # debug "#{p.botmodule_class} #{p.name} responds"
633 ret.push p.send(method, *args)
634 rescue Exception => err
635 raise if err.kind_of?(SystemExit)
636 error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
637 raise if err.kind_of?(BDB::Fatal)
642 # debug "Finished delegating #{method.inspect}"
645 # see if we have a plugin that wants to handle this message, if so, pass
646 # it to the plugin and return true, otherwise false
648 # debug "Delegating privmsg #{m.message.inspect} from #{m.source} to #{m.replyto} with pluginkey #{m.plugin.inspect}"
649 return unless m.plugin
651 if commands.has_key?(k)
652 p = commands[k][:botmodule]
653 a = commands[k][:auth]
654 # We check here for things that don't check themselves
655 # (e.g. mapped things)
656 # debug "Checking auth ..."
657 if a.nil? || @bot.auth.allow?(a, m.source, m.replyto)
658 # debug "Checking response ..."
659 if p.respond_to?("privmsg")
661 # debug "#{p.botmodule_class} #{p.name} responds"
663 rescue Exception => err
664 raise if err.kind_of?(SystemExit)
665 error report_error("#{p.botmodule_class} #{p.name} privmsg() failed:", err)
666 raise if err.kind_of?(BDB::Fatal)
668 # debug "Successfully delegated #{m.message}"
671 # debug "#{p.botmodule_class} #{p.name} is registered, but it doesn't respond to privmsg()"
674 # debug "#{p.botmodule_class} #{p.name} is registered, but #{m.source} isn't allowed to call #{m.plugin.inspect} on #{m.replyto}"
677 # debug "Finished delegating privmsg with key #{m.plugin.inspect}" + ( pl.empty? ? "" : " to #{pl.values.first[:botmodule].botmodule_class}s" )
679 # debug "Finished delegating privmsg with key #{m.plugin.inspect}"
683 # Returns the only PluginManagerClass instance
685 return PluginManagerClass.instance