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