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