]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - lib/rbot/plugins.rb
* don't set @failures_shown on debug(status)
[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       list
503     end
504
505     # return list of help topics (plugin names)
506     def helptopics
507       rv = status
508       @failures_shown = true
509       rv
510     end
511
512     def length
513       plugins.length
514     end
515
516     def core_length
517       core_modules.length
518     end
519
520     # return help for +topic+ (call associated plugin's help method)
521     def help(topic="")
522       case topic
523       when /fail(?:ed)?\s*plugins?.*(trace(?:back)?s?)?/
524         # debug "Failures: #{@failed.inspect}"
525         return "no plugins failed to load" if @failed.empty?
526         return @failed.inject(Array.new) { |list, p|
527           list << "#{Bold}#{p[:name]}#{Bold} in #{p[:dir]} failed"
528           list << "with error #{p[:reason].class}: #{p[:reason]}"
529           list << "at #{p[:reason].backtrace.join(', ')}" if $1 and not p[:reason].backtrace.empty?
530           list
531         }.join("\n")
532       when /ignored?\s*plugins?/
533         return "no plugins were ignored" if @ignored.empty?
534         return @ignored.inject(Array.new) { |list, p|
535           case p[:reason]
536           when :loaded
537             list << "#{p[:name]} in #{p[:dir]} (overruled by previous)"
538           else
539             list << "#{p[:name]} in #{p[:dir]} (#{p[:reason].to_s})"
540           end
541           list
542         }.join(", ")
543       when /^(\S+)\s*(.*)$/
544         key = $1
545         params = $2
546
547         # Let's see if we can match a plugin by the given name
548         (core_modules + plugins).each { |p|
549           next unless p.name == key
550           begin
551             return p.help(key, params)
552           rescue Exception => err
553             #rescue TimeoutError, StandardError, NameError, SyntaxError => err
554             error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
555           end
556         }
557
558         # Nope, let's see if it's a command, and ask for help at the corresponding botmodule
559         k = key.to_sym
560         if commands.has_key?(k)
561           p = commands[k][:botmodule] 
562           begin
563             return p.help(key, params)
564           rescue Exception => err
565             #rescue TimeoutError, StandardError, NameError, SyntaxError => err
566             error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
567           end
568         end
569       end
570       return false
571     end
572
573     # see if each plugin handles +method+, and if so, call it, passing
574     # +message+ as a parameter
575     def delegate(method, *args)
576       # debug "Delegating #{method.inspect}"
577       [core_modules, plugins].each { |pl|
578         pl.each {|p|
579           if(p.respond_to? method)
580             begin
581               # debug "#{p.botmodule_class} #{p.name} responds"
582               p.send method, *args
583             rescue Exception => err
584               raise if err.kind_of?(SystemExit)
585               error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
586               raise if err.kind_of?(BDB::Fatal)
587             end
588           end
589         }
590       }
591       # debug "Finished delegating #{method.inspect}"
592     end
593
594     # see if we have a plugin that wants to handle this message, if so, pass
595     # it to the plugin and return true, otherwise false
596     def privmsg(m)
597       # debug "Delegating privmsg #{m.message.inspect} from #{m.source} to #{m.replyto} with pluginkey #{m.plugin.inspect}"
598       return unless m.plugin
599       k = m.plugin.to_sym
600       if commands.has_key?(k)
601         p = commands[k][:botmodule]
602         a = commands[k][:auth]
603         # We check here for things that don't check themselves
604         # (e.g. mapped things)
605         # debug "Checking auth ..."
606         if a.nil? || @bot.auth.allow?(a, m.source, m.replyto)
607           # debug "Checking response ..."
608           if p.respond_to?("privmsg")
609             begin
610               # debug "#{p.botmodule_class} #{p.name} responds"
611               p.privmsg(m)
612             rescue Exception => err
613               raise if err.kind_of?(SystemExit)
614               error report_error("#{p.botmodule_class} #{p.name} privmsg() failed:", err)
615               raise if err.kind_of?(BDB::Fatal)
616             end
617             # debug "Successfully delegated #{m.message}"
618             return true
619           else
620             # debug "#{p.botmodule_class} #{p.name} is registered, but it doesn't respond to privmsg()"
621           end
622         else
623           # debug "#{p.botmodule_class} #{p.name} is registered, but #{m.source} isn't allowed to call #{m.plugin.inspect} on #{m.replyto}"
624         end
625       end
626       # debug "Finished delegating privmsg with key #{m.plugin.inspect}" + ( pl.empty? ? "" : " to #{pl.values.first[:botmodule].botmodule_class}s" )
627       return false
628       # debug "Finished delegating privmsg with key #{m.plugin.inspect}"
629     end
630   end
631
632   # Returns the only PluginManagerClass instance
633   def Plugins.manager
634     return PluginManagerClass.instance
635   end
636
637 end
638 end