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