]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - lib/rbot/plugins.rb
plugins: only show number of ignored and/or failed plugins only the first time a...
[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::manager
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       @failures_shown = false
281     end
282
283     # Associate with bot _bot_
284     def bot_associate(bot)
285       reset_botmodule_lists
286       @bot = bot
287     end
288
289     # Returns the botmodule with the given _name_
290     def [](name)
291       @names_hash[name.to_sym]
292     end
293
294     # Returns +true+ if _cmd_ has already been registered as a command
295     def who_handles?(cmd)
296       return nil unless @commandmappers.has_key?(cmd.to_sym)
297       return @commandmappers[cmd.to_sym][:botmodule]
298     end
299
300     # Registers botmodule _botmodule_ with command _cmd_ and command path _auth_path_
301     def register(botmodule, cmd, auth_path)
302       raise TypeError, "First argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
303       @commandmappers[cmd.to_sym] = {:botmodule => botmodule, :auth => auth_path}
304     end
305
306     def add_botmodule(botmodule)
307       raise TypeError, "Argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
308       kl = botmodule.botmodule_class
309       if @names_hash.has_key?(botmodule.to_sym)
310         case self[botmodule].botmodule_class
311         when kl
312           raise "#{kl} #{botmodule} already registered!"
313         else
314           raise "#{self[botmodule].botmodule_class} #{botmodule} already registered, cannot re-register as #{kl}"
315         end
316       end
317       @botmodules[kl] << botmodule
318       @names_hash[botmodule.to_sym] = botmodule
319     end
320
321     # Returns an array of the loaded plugins
322     def core_modules
323       @botmodules[:CoreBotModule]
324     end
325
326     # Returns an array of the loaded plugins
327     def plugins
328       @botmodules[:Plugin]
329     end
330
331     # Returns a hash of the registered message prefixes and associated
332     # plugins
333     def commands
334       @commandmappers
335     end
336
337     # Makes a string of error _err_ by adding text _str_
338     def report_error(str, err)
339       ([str, err.inspect] + err.backtrace).join("\n")
340     end
341
342     # This method is the one that actually loads a module from the
343     # file _fname_
344     #
345     # _desc_ is a simple description of what we are loading (plugin/botmodule/whatever)
346     #
347     # It returns the Symbol :loaded on success, and an Exception
348     # on failure
349     #
350     def load_botmodule_file(fname, desc=nil)
351       # create a new, anonymous module to "house" the plugin
352       # the idea here is to prevent namespace pollution. perhaps there
353       # is another way?
354       plugin_module = Module.new
355
356       desc = desc.to_s + " " if desc
357
358       begin
359         plugin_string = IO.readlines(fname).join("")
360         debug "loading #{desc}#{fname}"
361         plugin_module.module_eval(plugin_string, fname)
362         return :loaded
363       rescue Exception => err
364         # rescue TimeoutError, StandardError, NameError, LoadError, SyntaxError => err
365         warning report_error("#{desc}#{fname} load failed", err)
366         bt = err.backtrace.select { |line|
367           line.match(/^(\(eval\)|#{fname}):\d+/)
368         }
369         bt.map! { |el|
370           el.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
371             "#{fname}#{$1}#{$3}"
372           }
373         }
374         msg = err.to_str.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
375           "#{fname}#{$1}#{$3}"
376         }
377         newerr = err.class.new(msg)
378         newerr.set_backtrace(bt)
379         return newerr
380       end
381     end
382     private :load_botmodule_file
383
384     # add one or more directories to the list of directories to
385     # load botmodules from
386     #
387     # TODO find a way to specify necessary plugins which _must_ be loaded
388     #
389     def add_botmodule_dir(*dirlist)
390       @dirs += dirlist
391       debug "Botmodule loading path: #{@dirs.join(', ')}"
392     end
393
394     def clear_botmodule_dirs
395       @dirs.clear
396       debug "Botmodule loading path cleared"
397     end
398
399     # load plugins from pre-assigned list of directories
400     def scan
401       @failed.clear
402       @ignored.clear
403       processed = Hash.new
404
405       @bot.config['plugins.blacklist'].each { |p|
406         pn = p + ".rb"
407         processed[pn.intern] = :blacklisted
408       }
409
410       dirs = @dirs
411       dirs.each {|dir|
412         if(FileTest.directory?(dir))
413           d = Dir.new(dir)
414           d.sort.each {|file|
415
416             next if(file =~ /^\./)
417
418             if processed.has_key?(file.intern)
419               @ignored << {:name => file, :dir => dir, :reason => processed[file.intern]}
420               next
421             end
422
423             if(file =~ /^(.+\.rb)\.disabled$/)
424               # GB: Do we want to do this? This means that a disabled plugin in a directory
425               #     will disable in all subsequent directories. This was probably meant
426               #     to be used before plugins.blacklist was implemented, so I think
427               #     we don't need this anymore
428               processed[$1.intern] = :disabled
429               @ignored << {:name => $1, :dir => dir, :reason => processed[$1.intern]}
430               next
431             end
432
433             next unless(file =~ /\.rb$/)
434
435             did_it = load_botmodule_file("#{dir}/#{file}", "plugin")
436             case did_it
437             when Symbol
438               processed[file.intern] = did_it
439             when Exception
440               @failed <<  { :name => file, :dir => dir, :reason => did_it }
441             end
442
443           }
444         end
445       }
446       debug "finished loading plugins: #{status(true)}"
447     end
448
449     # call the save method for each active plugin
450     def save
451       delegate 'flush_registry'
452       delegate 'save'
453     end
454
455     # call the cleanup method for each active plugin
456     def cleanup
457       delegate 'cleanup'
458       reset_botmodule_lists
459     end
460
461     # drop all plugins and rescan plugins on disk
462     # calls save and cleanup for each plugin before dropping them
463     def rescan
464       save
465       cleanup
466       scan
467     end
468
469     def status(short=false)
470       list = ""
471       if self.core_length > 0
472         list << "#{self.core_length} core module#{'s' if core_length > 1}"
473         if short
474           list << " loaded"
475         else
476           list << ": " + core_modules.collect{ |p| p.name}.sort.join(", ")
477         end
478       else
479         list << "no core botmodules loaded"
480       end
481       # Active plugins first
482       if(self.length > 0)
483         list << "; #{self.length} plugin#{'s' if length > 1}"
484         if short
485           list << " loaded"
486         else
487           list << ": " + plugins.collect{ |p| p.name}.sort.join(", ")
488         end
489       else
490         list << "no plugins active"
491       end
492       # Ignored plugins next
493       unless @ignored.empty? or @failures_shown
494         list << "; #{Underline}#{@ignored.length} plugin#{'s' if @ignored.length > 1} ignored#{Underline}"
495         list << ": use #{Bold}help ignored plugins#{Bold} to see why" unless short
496       end
497       # Failed plugins next
498       unless @failed.empty? or @failures_shown
499         list << "; #{Reverse}#{@failed.length} plugin#{'s' if @failed.length > 1} failed to load#{Reverse}"
500         list << ": use #{Bold}help failed plugins#{Bold} to see why" unless short
501       end
502       @failures_shown = true
503       list
504     end
505
506     # return list of help topics (plugin names)
507     def helptopics
508       return status
509     end
510
511     def length
512       plugins.length
513     end
514
515     def core_length
516       core_modules.length
517     end
518
519     # return help for +topic+ (call associated plugin's help method)
520     def help(topic="")
521       case topic
522       when /fail(?:ed)?\s*plugins?.*(trace(?:back)?s?)?/
523         # debug "Failures: #{@failed.inspect}"
524         return "no plugins failed to load" if @failed.empty?
525         return @failed.inject(Array.new) { |list, p|
526           list << "#{Bold}#{p[:name]}#{Bold} in #{p[:dir]} failed"
527           list << "with error #{p[:reason].class}: #{p[:reason]}"
528           list << "at #{p[:reason].backtrace.join(', ')}" if $1 and not p[:reason].backtrace.empty?
529           list
530         }.join("\n")
531       when /ignored?\s*plugins?/
532         return "no plugins were ignored" if @ignored.empty?
533         return @ignored.inject(Array.new) { |list, p|
534           case p[:reason]
535           when :loaded
536             list << "#{p[:name]} in #{p[:dir]} (overruled by previous)"
537           else
538             list << "#{p[:name]} in #{p[:dir]} (#{p[:reason].to_s})"
539           end
540           list
541         }.join(", ")
542       when /^(\S+)\s*(.*)$/
543         key = $1
544         params = $2
545
546         # Let's see if we can match a plugin by the given name
547         (core_modules + plugins).each { |p|
548           next unless p.name == key
549           begin
550             return p.help(key, params)
551           rescue Exception => err
552             #rescue TimeoutError, StandardError, NameError, SyntaxError => err
553             error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
554           end
555         }
556
557         # Nope, let's see if it's a command, and ask for help at the corresponding botmodule
558         k = key.to_sym
559         if commands.has_key?(k)
560           p = commands[k][:botmodule] 
561           begin
562             return p.help(key, params)
563           rescue Exception => err
564             #rescue TimeoutError, StandardError, NameError, SyntaxError => err
565             error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
566           end
567         end
568       end
569       return false
570     end
571
572     # see if each plugin handles +method+, and if so, call it, passing
573     # +message+ as a parameter
574     def delegate(method, *args)
575       # debug "Delegating #{method.inspect}"
576       [core_modules, plugins].each { |pl|
577         pl.each {|p|
578           if(p.respond_to? method)
579             begin
580               # debug "#{p.botmodule_class} #{p.name} responds"
581               p.send method, *args
582             rescue Exception => err
583               raise if err.kind_of?(SystemExit)
584               error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
585               raise if err.kind_of?(BDB::Fatal)
586             end
587           end
588         }
589       }
590       # debug "Finished delegating #{method.inspect}"
591     end
592
593     # see if we have a plugin that wants to handle this message, if so, pass
594     # it to the plugin and return true, otherwise false
595     def privmsg(m)
596       # debug "Delegating privmsg #{m.message.inspect} from #{m.source} to #{m.replyto} with pluginkey #{m.plugin.inspect}"
597       return unless m.plugin
598       k = m.plugin.to_sym
599       if commands.has_key?(k)
600         p = commands[k][:botmodule]
601         a = commands[k][:auth]
602         # We check here for things that don't check themselves
603         # (e.g. mapped things)
604         # debug "Checking auth ..."
605         if a.nil? || @bot.auth.allow?(a, m.source, m.replyto)
606           # debug "Checking response ..."
607           if p.respond_to?("privmsg")
608             begin
609               # debug "#{p.botmodule_class} #{p.name} responds"
610               p.privmsg(m)
611             rescue Exception => err
612               raise if err.kind_of?(SystemExit)
613               error report_error("#{p.botmodule_class} #{p.name} privmsg() failed:", err)
614               raise if err.kind_of?(BDB::Fatal)
615             end
616             # debug "Successfully delegated #{m.message}"
617             return true
618           else
619             # debug "#{p.botmodule_class} #{p.name} is registered, but it doesn't respond to privmsg()"
620           end
621         else
622           # debug "#{p.botmodule_class} #{p.name} is registered, but #{m.source} isn't allowed to call #{m.plugin.inspect} on #{m.replyto}"
623         end
624       end
625       # debug "Finished delegating privmsg with key #{m.plugin.inspect}" + ( pl.empty? ? "" : " to #{pl.values.first[:botmodule].botmodule_class}s" )
626       return false
627       # debug "Finished delegating privmsg with key #{m.plugin.inspect}"
628     end
629   end
630
631   # Returns the only PluginManagerClass instance
632   def Plugins.manager
633     return PluginManagerClass.instance
634   end
635
636 end
637 end