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