]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - lib/rbot/plugins.rb
plugins.rb: use fast delegation hash
[user/henk/code/ruby/rbot.git] / lib / rbot / plugins.rb
1 #-- vim:sw=2:et
2 #++
3 #
4 # :title: rbot plugin management
5
6 require 'singleton'
7
8 module Irc
9     BotConfig.register BotConfigArrayValue.new('plugins.blacklist',
10       :default => [], :wizard => false, :requires_rescan => true,
11       :desc => "Plugins that should not be loaded")
12 module Plugins
13   require 'rbot/messagemapper'
14
15 =begin
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:
19
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.
26
27      Examples:
28
29        plugin.map 'karmastats', :action => 'karma_stats'
30
31        # while in the plugin...
32        def karma_stats(m, params)
33          m.reply "..."
34        end
35
36        # the default action is the first component
37        plugin.map 'karma'
38
39        # attributes can be pulled out of the match string
40        plugin.map 'karma for :key'
41        plugin.map 'karma :key'
42
43        # while in the plugin...
44        def karma(m, params)
45          item = params[:key]
46          m.reply 'karma for #{item}'
47        end
48
49        # you can setup defaults, to make parameters optional
50        plugin.map 'karma :key', :defaults => {:key => 'defaultvalue'}
51
52        # the default auth check is also against the first component
53        # but that can be changed
54        plugin.map 'karmastats', :auth => 'karma'
55
56        # maps can be restricted to public or private message:
57        plugin.map 'karmastats', :private false,
58        plugin.map 'karmastats', :public false,
59      end
60
61   listen(UserMessage)::
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,
66                          etc.
67
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
73                          to the sender.
74
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,
79                          if applicable.
80
81   unreplied(PrivMessage)::
82                          Called for a PRIVMSG which has not been replied to.
83
84   kick(KickMessage)::
85                          Called when a user (or the bot) is kicked from a
86                          channel the bot is in.
87
88   join(JoinMessage)::
89                          Called when a user (or the bot) joins a channel
90
91   part(PartMessage)::
92                          Called when a user (or the bot) parts a channel
93
94   quit(QuitMessage)::
95                          Called when a user (or the bot) quits IRC
96
97   nick(NickMessage)::
98                          Called when a user (or the bot) changes Nick
99   topic(TopicMessage)::
100                          Called when a user (or the bot) changes a channel
101                          topic
102
103   connect()::            Called when a server is joined successfully, but
104                          before autojoin channels are joined (no params)
105
106   set_language(String)::
107                          Called when the user sets a new language
108                          whose name is the given String
109
110   save::                 Called when you are required to save your plugin's
111                          state, if you maintain data between sessions
112
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
116 =end
117
118   class BotModule
119     attr_reader :bot   # the associated bot
120
121     # initialise your bot module. Always call super if you override this method,
122     # as important variables are set up for you
123     def initialize
124       @manager = Plugins::manager
125       @bot = @manager.bot
126
127       @botmodule_triggers = Array.new
128
129       @handler = MessageMapper.new(self)
130       @registry = BotRegistryAccessor.new(@bot, self.class.to_s.gsub(/^.*::/, ""))
131
132       @manager.add_botmodule(self)
133       if self.respond_to?('set_language')
134         self.set_language(@bot.lang.language)
135       end
136     end
137
138     def botmodule_class
139       :BotModule
140     end
141
142     def flush_registry
143       # debug "Flushing #{@registry}"
144       @registry.flush
145     end
146
147     def cleanup
148       # debug "Closing #{@registry}"
149       @registry.close
150     end
151
152     def handle(m)
153       @handler.handle(m)
154     end
155
156     def call_event(ev, *args)
157       @bot.plugins.delegate('event_' + ev.to_s.gsub(/[^\w\?!]+/, '_'), *args)
158     end
159
160     def map(*args)
161       @handler.map(self, *args)
162       # register this map
163       name = @handler.last.items[0]
164       self.register name, :auth => nil
165       unless self.respond_to?('privmsg')
166         def self.privmsg(m)
167           handle(m)
168         end
169       end
170     end
171
172     def map!(*args)
173       @handler.map(self, *args)
174       # register this map
175       name = @handler.last.items[0]
176       self.register name, :auth => nil, :hidden => true
177       unless self.respond_to?('privmsg')
178         def self.privmsg(m)
179           handle(m)
180         end
181       end
182     end
183
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
187     #
188     def default_auth(cmd, val, chan="*")
189       case cmd
190       when "*", ""
191         c = nil
192       else
193         c = cmd
194       end
195       Auth::defaultbotuser.set_default_permission(propose_default_path(c), val)
196     end
197
198     # Gets the default command path which would be given to command _cmd_
199     def propose_default_path(cmd)
200       [name, cmd].compact.join("::")
201     end
202
203     # return an identifier for this plugin, defaults to a list of the message
204     # prefixes handled (used for error messages etc)
205     def name
206       self.class.to_s.downcase.sub(/^#<module:.*?>::/,"").sub(/(plugin|module)?$/,"")
207     end
208
209     # just calls name
210     def to_s
211       name
212     end
213
214     # intern the name
215     def to_sym
216       self.name.to_sym
217     end
218
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)
225       "no help"
226     end
227
228     # register the plugin as a handler for messages prefixed +name+
229     # this can be called multiple times for a plugin to handle multiple
230     # message prefixes
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)
234       if who
235         raise "Command #{cmd} is already handled by #{who.botmodule_class} #{who}" if who != self
236         return
237       end
238       if opts.has_key?(:auth)
239         @manager.register(self, cmd, opts[:auth])
240       else
241         @manager.register(self, cmd, propose_default_path(cmd))
242       end
243       @botmodule_triggers << cmd unless opts.fetch(:hidden, false)
244     end
245
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}"})
250     end
251
252   end
253
254   class CoreBotModule < BotModule
255     def botmodule_class
256       :CoreBotModule
257     end
258   end
259
260   class Plugin < BotModule
261     def botmodule_class
262       :Plugin
263     end
264   end
265
266   # Singleton to manage multiple plugins and delegate messages to them for
267   # handling
268   class PluginManagerClass
269     include Singleton
270     attr_reader :bot
271     attr_reader :botmodules
272
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{^(?:
276       connect|names|nick|
277       listen|ctcp_listen|privmsg|unreplied|
278       kick|join|part|quit|
279       save|cleanup|flush_registry|
280       set_.*|event_.*
281     )$}x
282
283     def initialize
284       @botmodules = {
285         :CoreBotModule => [],
286         :Plugin => []
287       }
288
289       @names_hash = Hash.new
290       @commandmappers = Hash.new
291       @delegate_list = Hash.new { |h, k|
292         h[k] = Array.new
293       }
294
295       @dirs = []
296
297       @failed = Array.new
298       @ignored = Array.new
299
300       bot_associate(nil)
301     end
302
303     # Reset lists of botmodules
304     def reset_botmodule_lists
305       @botmodules[:CoreBotModule].clear
306       @botmodules[:Plugin].clear
307       @names_hash.clear
308       @commandmappers.clear
309       @failures_shown = false
310     end
311
312     # Associate with bot _bot_
313     def bot_associate(bot)
314       reset_botmodule_lists
315       @bot = bot
316     end
317
318     # Returns the botmodule with the given _name_
319     def [](name)
320       @names_hash[name.to_sym]
321     end
322
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]
327     end
328
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}
333     end
334
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
340         when kl
341           raise "#{kl} #{botmodule} already registered!"
342         else
343           raise "#{self[botmodule].botmodule_class} #{botmodule} already registered, cannot re-register as #{kl}"
344         end
345       end
346       @botmodules[kl] << botmodule
347       @names_hash[botmodule.to_sym] = botmodule
348     end
349
350     # Returns an array of the loaded plugins
351     def core_modules
352       @botmodules[:CoreBotModule]
353     end
354
355     # Returns an array of the loaded plugins
356     def plugins
357       @botmodules[:Plugin]
358     end
359
360     # Returns a hash of the registered message prefixes and associated
361     # plugins
362     def commands
363       @commandmappers
364     end
365
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")
369     end
370
371     # This method is the one that actually loads a module from the
372     # file _fname_
373     #
374     # _desc_ is a simple description of what we are loading (plugin/botmodule/whatever)
375     #
376     # It returns the Symbol :loaded on success, and an Exception
377     # on failure
378     #
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
382       # is another way?
383       plugin_module = Module.new
384
385       desc = desc.to_s + " " if desc
386
387       begin
388         plugin_string = IO.readlines(fname).join("")
389         debug "loading #{desc}#{fname}"
390         plugin_module.module_eval(plugin_string, fname)
391         return :loaded
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+/)
397         }
398         bt.map! { |el|
399           el.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
400             "#{fname}#{$1}#{$3}"
401           }
402         }
403         msg = err.to_str.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
404           "#{fname}#{$1}#{$3}"
405         }
406         newerr = err.class.new(msg)
407         newerr.set_backtrace(bt)
408         return newerr
409       end
410     end
411     private :load_botmodule_file
412
413     # add one or more directories to the list of directories to
414     # load botmodules from
415     #
416     # TODO find a way to specify necessary plugins which _must_ be loaded
417     #
418     def add_botmodule_dir(*dirlist)
419       @dirs += dirlist
420       debug "Botmodule loading path: #{@dirs.join(', ')}"
421     end
422
423     def clear_botmodule_dirs
424       @dirs.clear
425       debug "Botmodule loading path cleared"
426     end
427
428     # load plugins from pre-assigned list of directories
429     def scan
430       @failed.clear
431       @ignored.clear
432       @delegate_list.clear
433
434       processed = Hash.new
435
436       @bot.config['plugins.blacklist'].each { |p|
437         pn = p + ".rb"
438         processed[pn.intern] = :blacklisted
439       }
440
441       dirs = @dirs
442       dirs.each {|dir|
443         if(FileTest.directory?(dir))
444           d = Dir.new(dir)
445           d.sort.each {|file|
446
447             next if(file =~ /^\./)
448
449             if processed.has_key?(file.intern)
450               @ignored << {:name => file, :dir => dir, :reason => processed[file.intern]}
451               next
452             end
453
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]}
461               next
462             end
463
464             next unless(file =~ /\.rb$/)
465
466             did_it = load_botmodule_file("#{dir}/#{file}", "plugin")
467             case did_it
468             when Symbol
469               processed[file.intern] = did_it
470             when Exception
471               @failed <<  { :name => file, :dir => dir, :reason => did_it }
472             end
473
474           }
475         end
476       }
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
481        }
482       }
483     end
484
485     # call the save method for each active plugin
486     def save
487       delegate 'flush_registry'
488       delegate 'save'
489     end
490
491     # call the cleanup method for each active plugin
492     def cleanup
493       delegate 'cleanup'
494       reset_botmodule_lists
495     end
496
497     # drop all plugins and rescan plugins on disk
498     # calls save and cleanup for each plugin before dropping them
499     def rescan
500       save
501       cleanup
502       scan
503     end
504
505     def status(short=false)
506       output = []
507       if self.core_length > 0
508         if short
509           output << n_("%{count} core module loaded", "%{count} core modules loaded",
510                     self.core_length) % {:count => self.core_length}
511         else
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(", ") }
516         end
517       else
518         output << _("no core botmodules loaded")
519       end
520       # Active plugins first
521       if(self.length > 0)
522         if short
523           output << n_("%{count} plugin loaded", "%{count} plugins loaded",
524                        self.length) % {:count => self.length}
525         else
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(", ") }
530         end
531       else
532         output << "no plugins active"
533       end
534       # Ignored plugins next
535       unless @ignored.empty? or @failures_shown
536         if short
537           output << n_("%{highlight}%{count} plugin ignored%{highlight}",
538                        "%{highlight}%{count} plugins ignored%{highlight}",
539                        @ignored.length) %
540                     { :count => @ignored.length, :highlight => Underline }
541         else
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",
544                        @ignored.length) %
545                     { :count => @ignored.length, :highlight => Underline,
546                       :bold => Bold, :command => "help ignored plugins"}
547         end
548       end
549       # Failed plugins next
550       unless @failed.empty? or @failures_shown
551         if short
552           output << n_("%{highlight}%{count} plugin failed to load%{highlight}",
553                        "%{highlight}%{count} plugins failed to load%{highlight}",
554                        @failed.length) %
555                     { :count => @failed.length, :highlight => Reverse }
556         else
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",
559                        @failed.length) %
560                     { :count => @failed.length, :highlight => Reverse,
561                       :bold => Bold, :command => "help failed plugins"}
562         end
563       end
564       output.join '; '
565     end
566
567     # return list of help topics (plugin names)
568     def helptopics
569       rv = status
570       @failures_shown = true
571       rv
572     end
573
574     def length
575       plugins.length
576     end
577
578     def core_length
579       core_modules.length
580     end
581
582     # return help for +topic+ (call associated plugin's help method)
583     def help(topic="")
584       case topic
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(', ')}
594               else
595                 ''
596               end
597         }.join("\n")
598       when /ignored?\s*plugins?/
599         return _('no plugins were ignored') if @ignored.empty?
600
601         tmp = Hash.new
602         @ignored.each do |p|
603           reason = p[:loaded] ? _('overruled by previous') : _(p[:reason].to_s)
604           ((tmp[p[:dir]] ||= Hash.new)[reason] ||= Array.new).push(p[:name])
605         end
606
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})"
611           }.join('; ')
612           "in #{dir}: #{s}"
613         end.join('; ')
614       when /^(\S+)\s*(.*)$/
615         key = $1
616         params = $2
617
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
621           begin
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)
626           end
627         }
628
629         # Nope, let's see if it's a command, and ask for help at the corresponding botmodule
630         k = key.to_sym
631         if commands.has_key?(k)
632           p = commands[k][:botmodule]
633           begin
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)
638           end
639         end
640       end
641       return false
642     end
643
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}"
648       ret = Array.new
649       if method.match(DEFAULT_DELEGATE_PATTERNS)
650         debug "fast-delegating #{method}"
651         m = method.to_sym
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|
655           begin
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)
661           end
662         }
663       else
664         debug "slow-delegating #{method}"
665         (core_modules + plugins).each { |p|
666           if(p.respond_to? method)
667             begin
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)
674             end
675           end
676         }
677       end
678       return ret
679       # debug "Finished delegating #{method.inspect}"
680     end
681
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
684     def privmsg(m)
685       # debug "Delegating privmsg #{m.message.inspect} from #{m.source} to #{m.replyto} with pluginkey #{m.plugin.inspect}"
686       return unless m.plugin
687       k = m.plugin.to_sym
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")
697             begin
698               # debug "#{p.botmodule_class} #{p.name} responds"
699               p.privmsg(m)
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)
704             end
705             # debug "Successfully delegated #{m.message}"
706             return true
707           else
708             # debug "#{p.botmodule_class} #{p.name} is registered, but it doesn't respond to privmsg()"
709           end
710         else
711           # debug "#{p.botmodule_class} #{p.name} is registered, but #{m.source} isn't allowed to call #{m.plugin.inspect} on #{m.replyto}"
712         end
713       end
714       # debug "Finished delegating privmsg with key #{m.plugin.inspect}" + ( pl.empty? ? "" : " to #{pl.values.first[:botmodule].botmodule_class}s" )
715       return false
716       # debug "Finished delegating privmsg with key #{m.plugin.inspect}"
717     end
718   end
719
720   # Returns the only PluginManagerClass instance
721   def Plugins.manager
722     return PluginManagerClass.instance
723   end
724
725 end
726 end