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