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