]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - lib/rbot/plugins.rb
HttpUtil: require 'cgi' as it is now used in most querying plugins
[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
535         tmp = Hash.new
536         @ignored.each do |p|
537           reason = p[:loaded] ? 'overruled by previous' : p[:reason].to_s
538           ((tmp[p[:dir]] ||= Hash.new)[reason] ||= Array.new).push(p[:name])
539         end
540
541         return tmp.map do |dir, reasons|
542           s = reasons.map { |r, list|
543             list.map { |_| _.sub(/\.rb$/, '') }.join(', ') + " (#{r})"
544           }.join('; ')
545           "in #{dir}: #{s}"
546         end.join('; ')
547       when /^(\S+)\s*(.*)$/
548         key = $1
549         params = $2
550
551         # Let's see if we can match a plugin by the given name
552         (core_modules + plugins).each { |p|
553           next unless p.name == key
554           begin
555             return p.help(key, params)
556           rescue Exception => err
557             #rescue TimeoutError, StandardError, NameError, SyntaxError => err
558             error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
559           end
560         }
561
562         # Nope, let's see if it's a command, and ask for help at the corresponding botmodule
563         k = key.to_sym
564         if commands.has_key?(k)
565           p = commands[k][:botmodule] 
566           begin
567             return p.help(key, params)
568           rescue Exception => err
569             #rescue TimeoutError, StandardError, NameError, SyntaxError => err
570             error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
571           end
572         end
573       end
574       return false
575     end
576
577     # see if each plugin handles +method+, and if so, call it, passing
578     # +message+ as a parameter
579     def delegate(method, *args)
580       # debug "Delegating #{method.inspect}"
581       [core_modules, plugins].each { |pl|
582         pl.each {|p|
583           if(p.respond_to? method)
584             begin
585               # debug "#{p.botmodule_class} #{p.name} responds"
586               p.send method, *args
587             rescue Exception => err
588               raise if err.kind_of?(SystemExit)
589               error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
590               raise if err.kind_of?(BDB::Fatal)
591             end
592           end
593         }
594       }
595       # debug "Finished delegating #{method.inspect}"
596     end
597
598     # see if we have a plugin that wants to handle this message, if so, pass
599     # it to the plugin and return true, otherwise false
600     def privmsg(m)
601       # debug "Delegating privmsg #{m.message.inspect} from #{m.source} to #{m.replyto} with pluginkey #{m.plugin.inspect}"
602       return unless m.plugin
603       k = m.plugin.to_sym
604       if commands.has_key?(k)
605         p = commands[k][:botmodule]
606         a = commands[k][:auth]
607         # We check here for things that don't check themselves
608         # (e.g. mapped things)
609         # debug "Checking auth ..."
610         if a.nil? || @bot.auth.allow?(a, m.source, m.replyto)
611           # debug "Checking response ..."
612           if p.respond_to?("privmsg")
613             begin
614               # debug "#{p.botmodule_class} #{p.name} responds"
615               p.privmsg(m)
616             rescue Exception => err
617               raise if err.kind_of?(SystemExit)
618               error report_error("#{p.botmodule_class} #{p.name} privmsg() failed:", err)
619               raise if err.kind_of?(BDB::Fatal)
620             end
621             # debug "Successfully delegated #{m.message}"
622             return true
623           else
624             # debug "#{p.botmodule_class} #{p.name} is registered, but it doesn't respond to privmsg()"
625           end
626         else
627           # debug "#{p.botmodule_class} #{p.name} is registered, but #{m.source} isn't allowed to call #{m.plugin.inspect} on #{m.replyto}"
628         end
629       end
630       # debug "Finished delegating privmsg with key #{m.plugin.inspect}" + ( pl.empty? ? "" : " to #{pl.values.first[:botmodule].botmodule_class}s" )
631       return false
632       # debug "Finished delegating privmsg with key #{m.plugin.inspect}"
633     end
634   end
635
636   # Returns the only PluginManagerClass instance
637   def Plugins.manager
638     return PluginManagerClass.instance
639   end
640
641 end
642 end