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