]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - lib/rbot/plugins.rb
Allow customization of the plugins load path
[user/henk/code/ruby/rbot.git] / lib / rbot / plugins.rb
1 require 'singleton'
2
3 module Irc
4     BotConfig.register BotConfigArrayValue.new('plugins.blacklist',
5       :default => [], :wizard => false, :requires_rescan => true,
6       :desc => "Plugins that should not be loaded")
7 module Plugins
8   require 'rbot/messagemapper'
9
10 =begin
11   base class for all rbot plugins
12   certain methods will be called if they are provided, if you define one of
13   the following methods, it will be called as appropriate:
14
15   map(template, options)::
16   map!(template, options)::
17      map is the new, cleaner way to respond to specific message formats
18      without littering your plugin code with regexps. The difference
19      between map and map! is that map! will not register the new command
20      as an alternative name for the plugin.
21
22      Examples:
23
24        plugin.map 'karmastats', :action => 'karma_stats'
25
26        # while in the plugin...
27        def karma_stats(m, params)
28          m.reply "..."
29        end
30
31        # the default action is the first component
32        plugin.map 'karma'
33
34        # attributes can be pulled out of the match string
35        plugin.map 'karma for :key'
36        plugin.map 'karma :key'
37
38        # while in the plugin...
39        def karma(m, params)
40          item = params[:key]
41          m.reply 'karma for #{item}'
42        end
43
44        # you can setup defaults, to make parameters optional
45        plugin.map 'karma :key', :defaults => {:key => 'defaultvalue'}
46
47        # the default auth check is also against the first component
48        # but that can be changed
49        plugin.map 'karmastats', :auth => 'karma'
50
51        # maps can be restricted to public or private message:
52        plugin.map 'karmastats', :private false,
53        plugin.map 'karmastats', :public false,
54      end
55
56   listen(UserMessage)::
57                          Called for all messages of any type. To
58                          differentiate them, use message.kind_of? It'll be
59                          either a PrivMessage, NoticeMessage, KickMessage,
60                          QuitMessage, PartMessage, JoinMessage, NickMessage,
61                          etc.
62
63   privmsg(PrivMessage)::
64                          Called for a PRIVMSG if the first word matches one
65                          the plugin register()d for. Use m.plugin to get
66                          that word and m.params for the rest of the message,
67                          if applicable.
68
69   unreplied(PrivMessage)::
70                          Called for a PRIVMSG which has not been replied to.
71
72   kick(KickMessage)::
73                          Called when a user (or the bot) is kicked from a
74                          channel the bot is in.
75
76   join(JoinMessage)::
77                          Called when a user (or the bot) joins a channel
78
79   part(PartMessage)::
80                          Called when a user (or the bot) parts a channel
81
82   quit(QuitMessage)::
83                          Called when a user (or the bot) quits IRC
84
85   nick(NickMessage)::
86                          Called when a user (or the bot) changes Nick
87   topic(TopicMessage)::
88                          Called when a user (or the bot) changes a channel
89                          topic
90
91   connect()::            Called when a server is joined successfully, but
92                          before autojoin channels are joined (no params)
93
94   set_language(String)::
95                          Called when the user sets a new language
96                          whose name is the given String
97
98   save::                 Called when you are required to save your plugin's
99                          state, if you maintain data between sessions
100
101   cleanup::              called before your plugin is "unloaded", prior to a
102                          plugin reload or bot quit - close any open
103                          files/connections or flush caches here
104 =end
105
106   class BotModule
107     attr_reader :bot   # the associated bot
108
109     # initialise your bot module. Always call super if you override this method,
110     # as important variables are set up for you
111     def initialize
112       @manager = Plugins::pluginmanager
113       @bot = @manager.bot
114
115       @botmodule_triggers = Array.new
116
117       @handler = MessageMapper.new(self)
118       @registry = BotRegistryAccessor.new(@bot, self.class.to_s.gsub(/^.*::/, ""))
119
120       @manager.add_botmodule(self)
121       if self.respond_to?('set_language')
122         self.set_language(@bot.lang.language)
123       end
124     end
125
126     def botmodule_class
127       :BotModule
128     end
129
130     def flush_registry
131       # debug "Flushing #{@registry}"
132       @registry.flush
133     end
134
135     def cleanup
136       # debug "Closing #{@registry}"
137       @registry.close
138     end
139
140     def handle(m)
141       @handler.handle(m)
142     end
143
144     def map(*args)
145       @handler.map(self, *args)
146       # register this map
147       name = @handler.last.items[0]
148       self.register name, :auth => nil
149       unless self.respond_to?('privmsg')
150         def self.privmsg(m)
151           handle(m)
152         end
153       end
154     end
155
156     def map!(*args)
157       @handler.map(self, *args)
158       # register this map
159       name = @handler.last.items[0]
160       self.register name, :auth => nil, :hidden => true
161       unless self.respond_to?('privmsg')
162         def self.privmsg(m)
163           handle(m)
164         end
165       end
166     end
167
168     # Sets the default auth for command path _cmd_ to _val_ on channel _chan_:
169     # usually _chan_ is either "*" for everywhere, public and private (in which
170     # case it can be omitted) or "?" for private communications
171     #
172     def default_auth(cmd, val, chan="*")
173       case cmd
174       when "*", ""
175         c = nil
176       else
177         c = cmd
178       end
179       Auth::defaultbotuser.set_default_permission(propose_default_path(c), val)
180     end
181
182     # Gets the default command path which would be given to command _cmd_
183     def propose_default_path(cmd)
184       [name, cmd].compact.join("::")
185     end
186
187     # return an identifier for this plugin, defaults to a list of the message
188     # prefixes handled (used for error messages etc)
189     def name
190       self.class.to_s.downcase.sub(/^#<module:.*?>::/,"").sub(/(plugin|module)?$/,"")
191     end
192
193     # just calls name
194     def to_s
195       name
196     end
197
198     # intern the name
199     def to_sym
200       self.name.to_sym
201     end
202
203     # return a help string for your module. for complex modules, you may wish
204     # to break your help into topics, and return a list of available topics if
205     # +topic+ is nil. +plugin+ is passed containing the matching prefix for
206     # this message - if your plugin handles multiple prefixes, make sure you
207     # return the correct help for the prefix requested
208     def help(plugin, topic)
209       "no help"
210     end
211
212     # register the plugin as a handler for messages prefixed +name+
213     # this can be called multiple times for a plugin to handle multiple
214     # message prefixes
215     def register(cmd, opts={})
216       raise ArgumentError, "Second argument must be a hash!" unless opts.kind_of?(Hash)
217       who = @manager.who_handles?(cmd)
218       if who
219         raise "Command #{cmd} is already handled by #{who.botmodule_class} #{who}" if who != self
220         return
221       end
222       if opts.has_key?(:auth)
223         @manager.register(self, cmd, opts[:auth])
224       else
225         @manager.register(self, cmd, propose_default_path(cmd))
226       end
227       @botmodule_triggers << cmd unless opts.fetch(:hidden, false)
228     end
229
230     # default usage method provided as a utility for simple plugins. The
231     # MessageMapper uses 'usage' as its default fallback method.
232     def usage(m, params = {})
233       m.reply "incorrect usage, ask for help using '#{@bot.nick}: help #{m.plugin}'"
234     end
235
236   end
237
238   class CoreBotModule < BotModule
239     def botmodule_class
240       :CoreBotModule
241     end
242   end
243
244   class Plugin < BotModule
245     def botmodule_class
246       :Plugin
247     end
248   end
249
250   # Singleton to manage multiple plugins and delegate messages to them for
251   # handling
252   class PluginManagerClass
253     include Singleton
254     attr_reader :bot
255     attr_reader :botmodules
256
257     def initialize
258       @botmodules = {
259         :CoreBotModule => [],
260         :Plugin => []
261       }
262
263       @names_hash = Hash.new
264       @commandmappers = Hash.new
265
266       @dirs = []
267
268       @failed = Array.new
269       @ignored = Array.new
270
271       bot_associate(nil)
272     end
273
274     # Reset lists of botmodules
275     def reset_botmodule_lists
276       @botmodules[:CoreBotModule].clear
277       @botmodules[:Plugin].clear
278       @names_hash.clear
279       @commandmappers.clear
280     end
281
282     # Associate with bot _bot_
283     def bot_associate(bot)
284       reset_botmodule_lists
285       @bot = bot
286     end
287
288     # Returns the botmodule with the given _name_
289     def [](name)
290       @names_hash[name.to_sym]
291     end
292
293     # Returns +true+ if _cmd_ has already been registered as a command
294     def who_handles?(cmd)
295       return nil unless @commandmappers.has_key?(cmd.to_sym)
296       return @commandmappers[cmd.to_sym][:botmodule]
297     end
298
299     # Registers botmodule _botmodule_ with command _cmd_ and command path _auth_path_
300     def register(botmodule, cmd, auth_path)
301       raise TypeError, "First argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
302       @commandmappers[cmd.to_sym] = {:botmodule => botmodule, :auth => auth_path}
303     end
304
305     def add_botmodule(botmodule)
306       raise TypeError, "Argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
307       kl = botmodule.botmodule_class
308       if @names_hash.has_key?(botmodule.to_sym)
309         case self[botmodule].botmodule_class
310         when kl
311           raise "#{kl} #{botmodule} already registered!"
312         else
313           raise "#{self[botmodule].botmodule_class} #{botmodule} already registered, cannot re-register as #{kl}"
314         end
315       end
316       @botmodules[kl] << botmodule
317       @names_hash[botmodule.to_sym] = botmodule
318     end
319
320     # Returns an array of the loaded plugins
321     def core_modules
322       @botmodules[:CoreBotModule]
323     end
324
325     # Returns an array of the loaded plugins
326     def plugins
327       @botmodules[:Plugin]
328     end
329
330     # Returns a hash of the registered message prefixes and associated
331     # plugins
332     def commands
333       @commandmappers
334     end
335
336     # Makes a string of error _err_ by adding text _str_
337     def report_error(str, err)
338       ([str, err.inspect] + err.backtrace).join("\n")
339     end
340
341     # This method is the one that actually loads a module from the
342     # file _fname_
343     #
344     # _desc_ is a simple description of what we are loading (plugin/botmodule/whatever)
345     #
346     # It returns the Symbol :loaded on success, and an Exception
347     # on failure
348     #
349     def load_botmodule_file(fname, desc=nil)
350       # create a new, anonymous module to "house" the plugin
351       # the idea here is to prevent namespace pollution. perhaps there
352       # is another way?
353       plugin_module = Module.new
354
355       desc = desc.to_s + " " if desc
356
357       begin
358         plugin_string = IO.readlines(fname).join("")
359         debug "loading #{desc}#{fname}"
360         plugin_module.module_eval(plugin_string, fname)
361         return :loaded
362       rescue Exception => err
363         # rescue TimeoutError, StandardError, NameError, LoadError, SyntaxError => err
364         warning report_error("#{desc}#{fname} load failed", err)
365         bt = err.backtrace.select { |line|
366           line.match(/^(\(eval\)|#{fname}):\d+/)
367         }
368         bt.map! { |el|
369           el.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
370             "#{fname}#{$1}#{$3}"
371           }
372         }
373         msg = err.to_str.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
374           "#{fname}#{$1}#{$3}"
375         }
376         newerr = err.class.new(msg)
377         newerr.set_backtrace(bt)
378         return newerr
379       end
380     end
381     private :load_botmodule_file
382
383     # add one or more directories to the list of directories to
384     # load botmodules from
385     #
386     # TODO find a way to specify necessary plugins which _must_ be loaded
387     #
388     def add_botmodule_dir(*dirlist)
389       @dirs += dirlist
390       debug "Botmodule loading path: #{@dirs.join(', ')}"
391     end
392
393     def clear_botmodule_dirs
394       @dirs.clear
395       debug "Botmodule loading path cleared"
396     end
397
398     # load plugins from pre-assigned list of directories
399     def scan
400       @failed.clear
401       @ignored.clear
402       processed = Hash.new
403
404       @bot.config['plugins.blacklist'].each { |p|
405         pn = p + ".rb"
406         processed[pn.intern] = :blacklisted
407       }
408
409       dirs = @dirs
410       dirs.each {|dir|
411         if(FileTest.directory?(dir))
412           d = Dir.new(dir)
413           d.sort.each {|file|
414
415             next if(file =~ /^\./)
416
417             if processed.has_key?(file.intern)
418               @ignored << {:name => file, :dir => dir, :reason => processed[file.intern]}
419               next
420             end
421
422             if(file =~ /^(.+\.rb)\.disabled$/)
423               # GB: Do we want to do this? This means that a disabled plugin in a directory
424               #     will disable in all subsequent directories. This was probably meant
425               #     to be used before plugins.blacklist was implemented, so I think
426               #     we don't need this anymore
427               processed[$1.intern] = :disabled
428               @ignored << {:name => $1, :dir => dir, :reason => processed[$1.intern]}
429               next
430             end
431
432             next unless(file =~ /\.rb$/)
433
434             did_it = load_botmodule_file("#{dir}/#{file}", "plugin")
435             case did_it
436             when Symbol
437               processed[file.intern] = did_it
438             when Exception
439               @failed <<  { :name => file, :dir => dir, :reason => did_it }
440             end
441
442           }
443         end
444       }
445       debug "finished loading plugins: #{status(true)}"
446     end
447
448     # call the save method for each active plugin
449     def save
450       delegate 'flush_registry'
451       delegate 'save'
452     end
453
454     # call the cleanup method for each active plugin
455     def cleanup
456       delegate 'cleanup'
457       reset_botmodule_lists
458     end
459
460     # drop all plugins and rescan plugins on disk
461     # calls save and cleanup for each plugin before dropping them
462     def rescan
463       save
464       cleanup
465       scan
466     end
467
468     def status(short=false)
469       list = ""
470       if self.core_length > 0
471         list << "#{self.core_length} core module#{'s' if core_length > 1}"
472         if short
473           list << " loaded"
474         else
475           list << ": " + core_modules.collect{ |p| p.name}.sort.join(", ")
476         end
477       else
478         list << "no core botmodules loaded"
479       end
480       # Active plugins first
481       if(self.length > 0)
482         list << "; #{self.length} plugin#{'s' if length > 1}"
483         if short
484           list << " loaded"
485         else
486           list << ": " + plugins.collect{ |p| p.name}.sort.join(", ")
487         end
488       else
489         list << "no plugins active"
490       end
491       # Ignored plugins next
492       unless @ignored.empty?
493         list << "; #{Underline}#{@ignored.length} plugin#{'s' if @ignored.length > 1} ignored#{Underline}"
494         list << ": use #{Bold}help ignored plugins#{Bold} to see why" unless short
495       end
496       # Failed plugins next
497       unless @failed.empty?
498         list << "; #{Reverse}#{@failed.length} plugin#{'s' if @failed.length > 1} failed to load#{Reverse}"
499         list << ": use #{Bold}help failed plugins#{Bold} to see why" unless short
500       end
501       list
502     end
503
504     # return list of help topics (plugin names)
505     def helptopics
506       return status
507     end
508
509     def length
510       plugins.length
511     end
512
513     def core_length
514       core_modules.length
515     end
516
517     # return help for +topic+ (call associated plugin's help method)
518     def help(topic="")
519       case topic
520       when /fail(?:ed)?\s*plugins?.*(trace(?:back)?s?)?/
521         # debug "Failures: #{@failed.inspect}"
522         return "no plugins failed to load" if @failed.empty?
523         return @failed.inject(Array.new) { |list, p|
524           list << "#{Bold}#{p[:name]}#{Bold} in #{p[:dir]} failed"
525           list << "with error #{p[:reason].class}: #{p[:reason]}"
526           list << "at #{p[:reason].backtrace.join(', ')}" if $1 and not p[:reason].backtrace.empty?
527           list
528         }.join("\n")
529       when /ignored?\s*plugins?/
530         return "no plugins were ignored" if @ignored.empty?
531         return @ignored.inject(Array.new) { |list, p|
532           case p[:reason]
533           when :loaded
534             list << "#{p[:name]} in #{p[:dir]} (overruled by previous)"
535           else
536             list << "#{p[:name]} in #{p[:dir]} (#{p[:reason].to_s})"
537           end
538           list
539         }.join(", ")
540       when /^(\S+)\s*(.*)$/
541         key = $1
542         params = $2
543
544         # Let's see if we can match a plugin by the given name
545         (core_modules + plugins).each { |p|
546           next unless p.name == key
547           begin
548             return p.help(key, params)
549           rescue Exception => err
550             #rescue TimeoutError, StandardError, NameError, SyntaxError => err
551             error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
552           end
553         }
554
555         # Nope, let's see if it's a command, and ask for help at the corresponding botmodule
556         k = key.to_sym
557         if commands.has_key?(k)
558           p = commands[k][:botmodule] 
559           begin
560             return p.help(key, params)
561           rescue Exception => err
562             #rescue TimeoutError, StandardError, NameError, SyntaxError => err
563             error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
564           end
565         end
566       end
567       return false
568     end
569
570     # see if each plugin handles +method+, and if so, call it, passing
571     # +message+ as a parameter
572     def delegate(method, *args)
573       # debug "Delegating #{method.inspect}"
574       [core_modules, plugins].each { |pl|
575         pl.each {|p|
576           if(p.respond_to? method)
577             begin
578               # debug "#{p.botmodule_class} #{p.name} responds"
579               p.send method, *args
580             rescue Exception => err
581               raise if err.kind_of?(SystemExit)
582               error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
583               raise if err.kind_of?(BDB::Fatal)
584             end
585           end
586         }
587       }
588       # debug "Finished delegating #{method.inspect}"
589     end
590
591     # see if we have a plugin that wants to handle this message, if so, pass
592     # it to the plugin and return true, otherwise false
593     def privmsg(m)
594       # debug "Delegating privmsg #{m.message.inspect} from #{m.source} to #{m.replyto} with pluginkey #{m.plugin.inspect}"
595       return unless m.plugin
596       k = m.plugin.to_sym
597       if commands.has_key?(k)
598         p = commands[k][:botmodule]
599         a = commands[k][:auth]
600         # We check here for things that don't check themselves
601         # (e.g. mapped things)
602         # debug "Checking auth ..."
603         if a.nil? || @bot.auth.allow?(a, m.source, m.replyto)
604           # debug "Checking response ..."
605           if p.respond_to?("privmsg")
606             begin
607               # debug "#{p.botmodule_class} #{p.name} responds"
608               p.privmsg(m)
609             rescue Exception => err
610               raise if err.kind_of?(SystemExit)
611               error report_error("#{p.botmodule_class} #{p.name} privmsg() failed:", err)
612               raise if err.kind_of?(BDB::Fatal)
613             end
614             # debug "Successfully delegated #{m.message}"
615             return true
616           else
617             # debug "#{p.botmodule_class} #{p.name} is registered, but it doesn't respond to privmsg()"
618           end
619         else
620           # debug "#{p.botmodule_class} #{p.name} is registered, but #{m.source} isn't allowed to call #{m.plugin.inspect} on #{m.replyto}"
621         end
622       end
623       # debug "Finished delegating privmsg with key #{m.plugin.inspect}" + ( pl.empty? ? "" : " to #{pl.values.first[:botmodule].botmodule_class}s" )
624       return false
625       # debug "Finished delegating privmsg with key #{m.plugin.inspect}"
626     end
627   end
628
629   # Returns the only PluginManagerClass instance
630   def Plugins.pluginmanager
631     return PluginManagerClass.instance
632   end
633
634 end
635 end