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