]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - lib/rbot/plugins.rb
+ ctcp_listen plugin delegate
[user/henk/code/ruby/rbot.git] / lib / rbot / plugins.rb
1 #-- vim:sw=2:et
2 #++
3 #
4 # :title: rbot plugin management
5
6 require 'singleton'
7
8 module Irc
9     BotConfig.register BotConfigArrayValue.new('plugins.blacklist',
10       :default => [], :wizard => false, :requires_rescan => true,
11       :desc => "Plugins that should not be loaded")
12 module Plugins
13   require 'rbot/messagemapper'
14
15 =begin
16   base class for all rbot plugins
17   certain methods will be called if they are provided, if you define one of
18   the following methods, it will be called as appropriate:
19
20   map(template, options)::
21   map!(template, options)::
22      map is the new, cleaner way to respond to specific message formats
23      without littering your plugin code with regexps. The difference
24      between map and map! is that map! will not register the new command
25      as an alternative name for the plugin.
26
27      Examples:
28
29        plugin.map 'karmastats', :action => 'karma_stats'
30
31        # while in the plugin...
32        def karma_stats(m, params)
33          m.reply "..."
34        end
35
36        # the default action is the first component
37        plugin.map 'karma'
38
39        # attributes can be pulled out of the match string
40        plugin.map 'karma for :key'
41        plugin.map 'karma :key'
42
43        # while in the plugin...
44        def karma(m, params)
45          item = params[:key]
46          m.reply 'karma for #{item}'
47        end
48
49        # you can setup defaults, to make parameters optional
50        plugin.map 'karma :key', :defaults => {:key => 'defaultvalue'}
51
52        # the default auth check is also against the first component
53        # but that can be changed
54        plugin.map 'karmastats', :auth => 'karma'
55
56        # maps can be restricted to public or private message:
57        plugin.map 'karmastats', :private false,
58        plugin.map 'karmastats', :public false,
59      end
60
61   listen(UserMessage)::
62                          Called for all messages of any type. To
63                          differentiate them, use message.kind_of? It'll be
64                          either a PrivMessage, NoticeMessage, KickMessage,
65                          QuitMessage, PartMessage, JoinMessage, NickMessage,
66                          etc.
67
68   ctcp_listen(UserMessage)::
69                          Called for all messages that contain a CTCP command.
70                          Use message.ctcp to get the CTCP command, and
71                          message.message to get the parameter string. To reply,
72                          use message.ctcp_reply, which sends a private NOTICE
73                          to the sender.
74
75   privmsg(PrivMessage)::
76                          Called for a PRIVMSG if the first word matches one
77                          the plugin register()d for. Use m.plugin to get
78                          that word and m.params for the rest of the message,
79                          if applicable.
80
81   unreplied(PrivMessage)::
82                          Called for a PRIVMSG which has not been replied to.
83
84   kick(KickMessage)::
85                          Called when a user (or the bot) is kicked from a
86                          channel the bot is in.
87
88   join(JoinMessage)::
89                          Called when a user (or the bot) joins a channel
90
91   part(PartMessage)::
92                          Called when a user (or the bot) parts a channel
93
94   quit(QuitMessage)::
95                          Called when a user (or the bot) quits IRC
96
97   nick(NickMessage)::
98                          Called when a user (or the bot) changes Nick
99   topic(TopicMessage)::
100                          Called when a user (or the bot) changes a channel
101                          topic
102
103   connect()::            Called when a server is joined successfully, but
104                          before autojoin channels are joined (no params)
105
106   set_language(String)::
107                          Called when the user sets a new language
108                          whose name is the given String
109
110   save::                 Called when you are required to save your plugin's
111                          state, if you maintain data between sessions
112
113   cleanup::              called before your plugin is "unloaded", prior to a
114                          plugin reload or bot quit - close any open
115                          files/connections or flush caches here
116 =end
117
118   class BotModule
119     attr_reader :bot   # the associated bot
120
121     # initialise your bot module. Always call super if you override this method,
122     # as important variables are set up for you
123     def initialize
124       @manager = Plugins::manager
125       @bot = @manager.bot
126
127       @botmodule_triggers = Array.new
128
129       @handler = MessageMapper.new(self)
130       @registry = BotRegistryAccessor.new(@bot, self.class.to_s.gsub(/^.*::/, ""))
131
132       @manager.add_botmodule(self)
133       if self.respond_to?('set_language')
134         self.set_language(@bot.lang.language)
135       end
136     end
137
138     def botmodule_class
139       :BotModule
140     end
141
142     def flush_registry
143       # debug "Flushing #{@registry}"
144       @registry.flush
145     end
146
147     def cleanup
148       # debug "Closing #{@registry}"
149       @registry.close
150     end
151
152     def handle(m)
153       @handler.handle(m)
154     end
155
156     def call_event(ev, *args)
157       @bot.plugins.delegate('event_' + ev.to_s.gsub(/[^\w\?!]+/, '_'), *args)
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
165       unless self.respond_to?('privmsg')
166         def self.privmsg(m)
167           handle(m)
168         end
169       end
170     end
171
172     def map!(*args)
173       @handler.map(self, *args)
174       # register this map
175       name = @handler.last.items[0]
176       self.register name, :auth => nil, :hidden => true
177       unless self.respond_to?('privmsg')
178         def self.privmsg(m)
179           handle(m)
180         end
181       end
182     end
183
184     # Sets the default auth for command path _cmd_ to _val_ on channel _chan_:
185     # usually _chan_ is either "*" for everywhere, public and private (in which
186     # case it can be omitted) or "?" for private communications
187     #
188     def default_auth(cmd, val, chan="*")
189       case cmd
190       when "*", ""
191         c = nil
192       else
193         c = cmd
194       end
195       Auth::defaultbotuser.set_default_permission(propose_default_path(c), val)
196     end
197
198     # Gets the default command path which would be given to command _cmd_
199     def propose_default_path(cmd)
200       [name, cmd].compact.join("::")
201     end
202
203     # return an identifier for this plugin, defaults to a list of the message
204     # prefixes handled (used for error messages etc)
205     def name
206       self.class.to_s.downcase.sub(/^#<module:.*?>::/,"").sub(/(plugin|module)?$/,"")
207     end
208
209     # just calls name
210     def to_s
211       name
212     end
213
214     # intern the name
215     def to_sym
216       self.name.to_sym
217     end
218
219     # return a help string for your module. for complex modules, you may wish
220     # to break your help into topics, and return a list of available topics if
221     # +topic+ is nil. +plugin+ is passed containing the matching prefix for
222     # this message - if your plugin handles multiple prefixes, make sure you
223     # return the correct help for the prefix requested
224     def help(plugin, topic)
225       "no help"
226     end
227
228     # register the plugin as a handler for messages prefixed +name+
229     # this can be called multiple times for a plugin to handle multiple
230     # message prefixes
231     def register(cmd, opts={})
232       raise ArgumentError, "Second argument must be a hash!" unless opts.kind_of?(Hash)
233       who = @manager.who_handles?(cmd)
234       if who
235         raise "Command #{cmd} is already handled by #{who.botmodule_class} #{who}" if who != self
236         return
237       end
238       if opts.has_key?(:auth)
239         @manager.register(self, cmd, opts[:auth])
240       else
241         @manager.register(self, cmd, propose_default_path(cmd))
242       end
243       @botmodule_triggers << cmd unless opts.fetch(:hidden, false)
244     end
245
246     # default usage method provided as a utility for simple plugins. The
247     # MessageMapper uses 'usage' as its default fallback method.
248     def usage(m, params = {})
249       m.reply(_("incorrect usage, ask for help using '%{command}'") % {:command => "#{@bot.nick}: help #{m.plugin}"})
250     end
251
252   end
253
254   class CoreBotModule < BotModule
255     def botmodule_class
256       :CoreBotModule
257     end
258   end
259
260   class Plugin < BotModule
261     def botmodule_class
262       :Plugin
263     end
264   end
265
266   # Singleton to manage multiple plugins and delegate messages to them for
267   # handling
268   class PluginManagerClass
269     include Singleton
270     attr_reader :bot
271     attr_reader :botmodules
272
273     def initialize
274       @botmodules = {
275         :CoreBotModule => [],
276         :Plugin => []
277       }
278
279       @names_hash = Hash.new
280       @commandmappers = Hash.new
281
282       @dirs = []
283
284       @failed = Array.new
285       @ignored = Array.new
286
287       bot_associate(nil)
288     end
289
290     # Reset lists of botmodules
291     def reset_botmodule_lists
292       @botmodules[:CoreBotModule].clear
293       @botmodules[:Plugin].clear
294       @names_hash.clear
295       @commandmappers.clear
296       @failures_shown = false
297     end
298
299     # Associate with bot _bot_
300     def bot_associate(bot)
301       reset_botmodule_lists
302       @bot = bot
303     end
304
305     # Returns the botmodule with the given _name_
306     def [](name)
307       @names_hash[name.to_sym]
308     end
309
310     # Returns +true+ if _cmd_ has already been registered as a command
311     def who_handles?(cmd)
312       return nil unless @commandmappers.has_key?(cmd.to_sym)
313       return @commandmappers[cmd.to_sym][:botmodule]
314     end
315
316     # Registers botmodule _botmodule_ with command _cmd_ and command path _auth_path_
317     def register(botmodule, cmd, auth_path)
318       raise TypeError, "First argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
319       @commandmappers[cmd.to_sym] = {:botmodule => botmodule, :auth => auth_path}
320     end
321
322     def add_botmodule(botmodule)
323       raise TypeError, "Argument #{botmodule.inspect} is not of class BotModule" unless botmodule.kind_of?(BotModule)
324       kl = botmodule.botmodule_class
325       if @names_hash.has_key?(botmodule.to_sym)
326         case self[botmodule].botmodule_class
327         when kl
328           raise "#{kl} #{botmodule} already registered!"
329         else
330           raise "#{self[botmodule].botmodule_class} #{botmodule} already registered, cannot re-register as #{kl}"
331         end
332       end
333       @botmodules[kl] << botmodule
334       @names_hash[botmodule.to_sym] = botmodule
335     end
336
337     # Returns an array of the loaded plugins
338     def core_modules
339       @botmodules[:CoreBotModule]
340     end
341
342     # Returns an array of the loaded plugins
343     def plugins
344       @botmodules[:Plugin]
345     end
346
347     # Returns a hash of the registered message prefixes and associated
348     # plugins
349     def commands
350       @commandmappers
351     end
352
353     # Makes a string of error _err_ by adding text _str_
354     def report_error(str, err)
355       ([str, err.inspect] + err.backtrace).join("\n")
356     end
357
358     # This method is the one that actually loads a module from the
359     # file _fname_
360     #
361     # _desc_ is a simple description of what we are loading (plugin/botmodule/whatever)
362     #
363     # It returns the Symbol :loaded on success, and an Exception
364     # on failure
365     #
366     def load_botmodule_file(fname, desc=nil)
367       # create a new, anonymous module to "house" the plugin
368       # the idea here is to prevent namespace pollution. perhaps there
369       # is another way?
370       plugin_module = Module.new
371
372       desc = desc.to_s + " " if desc
373
374       begin
375         plugin_string = IO.readlines(fname).join("")
376         debug "loading #{desc}#{fname}"
377         plugin_module.module_eval(plugin_string, fname)
378         return :loaded
379       rescue Exception => err
380         # rescue TimeoutError, StandardError, NameError, LoadError, SyntaxError => err
381         error report_error("#{desc}#{fname} load failed", err)
382         bt = err.backtrace.select { |line|
383           line.match(/^(\(eval\)|#{fname}):\d+/)
384         }
385         bt.map! { |el|
386           el.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
387             "#{fname}#{$1}#{$3}"
388           }
389         }
390         msg = err.to_str.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
391           "#{fname}#{$1}#{$3}"
392         }
393         newerr = err.class.new(msg)
394         newerr.set_backtrace(bt)
395         return newerr
396       end
397     end
398     private :load_botmodule_file
399
400     # add one or more directories to the list of directories to
401     # load botmodules from
402     #
403     # TODO find a way to specify necessary plugins which _must_ be loaded
404     #
405     def add_botmodule_dir(*dirlist)
406       @dirs += dirlist
407       debug "Botmodule loading path: #{@dirs.join(', ')}"
408     end
409
410     def clear_botmodule_dirs
411       @dirs.clear
412       debug "Botmodule loading path cleared"
413     end
414
415     # load plugins from pre-assigned list of directories
416     def scan
417       @failed.clear
418       @ignored.clear
419       processed = Hash.new
420
421       @bot.config['plugins.blacklist'].each { |p|
422         pn = p + ".rb"
423         processed[pn.intern] = :blacklisted
424       }
425
426       dirs = @dirs
427       dirs.each {|dir|
428         if(FileTest.directory?(dir))
429           d = Dir.new(dir)
430           d.sort.each {|file|
431
432             next if(file =~ /^\./)
433
434             if processed.has_key?(file.intern)
435               @ignored << {:name => file, :dir => dir, :reason => processed[file.intern]}
436               next
437             end
438
439             if(file =~ /^(.+\.rb)\.disabled$/)
440               # GB: Do we want to do this? This means that a disabled plugin in a directory
441               #     will disable in all subsequent directories. This was probably meant
442               #     to be used before plugins.blacklist was implemented, so I think
443               #     we don't need this anymore
444               processed[$1.intern] = :disabled
445               @ignored << {:name => $1, :dir => dir, :reason => processed[$1.intern]}
446               next
447             end
448
449             next unless(file =~ /\.rb$/)
450
451             did_it = load_botmodule_file("#{dir}/#{file}", "plugin")
452             case did_it
453             when Symbol
454               processed[file.intern] = did_it
455             when Exception
456               @failed <<  { :name => file, :dir => dir, :reason => did_it }
457             end
458
459           }
460         end
461       }
462       debug "finished loading plugins: #{status(true)}"
463     end
464
465     # call the save method for each active plugin
466     def save
467       delegate 'flush_registry'
468       delegate 'save'
469     end
470
471     # call the cleanup method for each active plugin
472     def cleanup
473       delegate 'cleanup'
474       reset_botmodule_lists
475     end
476
477     # drop all plugins and rescan plugins on disk
478     # calls save and cleanup for each plugin before dropping them
479     def rescan
480       save
481       cleanup
482       scan
483     end
484
485     def status(short=false)
486       output = []
487       if self.core_length > 0
488         if short
489           output << n_("%{count} core module loaded", "%{count} core modules loaded",
490                     self.core_length) % {:count => self.core_length}
491         else
492           output <<  n_("%{count} core module: %{list}",
493                      "%{count} core modules: %{list}", self.core_length) %
494                      { :count => self.core_length,
495                        :list => core_modules.collect{ |p| p.name}.sort.join(", ") }
496         end
497       else
498         output << _("no core botmodules loaded")
499       end
500       # Active plugins first
501       if(self.length > 0)
502         if short
503           output << n_("%{count} plugin loaded", "%{count} plugins loaded",
504                        self.length) % {:count => self.length}
505         else
506           output << n_("%{count} plugin: %{list}",
507                        "%{count} plugins: %{list}", self.length) %
508                    { :count => self.length,
509                      :list => plugins.collect{ |p| p.name}.sort.join(", ") }
510         end
511       else
512         output << "no plugins active"
513       end
514       # Ignored plugins next
515       unless @ignored.empty? or @failures_shown
516         if short
517           output << n_("%{highlight}%{count} plugin ignored%{highlight}",
518                        "%{highlight}%{count} plugins ignored%{highlight}",
519                        @ignored.length) %
520                     { :count => @ignored.length, :highlight => Underline }
521         else
522           output << n_("%{highlight}%{count} plugin ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
523                        "%{highlight}%{count} plugins ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
524                        @ignored.length) %
525                     { :count => @ignored.length, :highlight => Underline,
526                       :bold => Bold, :command => "help ignored plugins"}
527         end
528       end
529       # Failed plugins next
530       unless @failed.empty? or @failures_shown
531         if short
532           output << n_("%{highlight}%{count} plugin failed to load%{highlight}",
533                        "%{highlight}%{count} plugins failed to load%{highlight}",
534                        @failed.length) %
535                     { :count => @failed.length, :highlight => Reverse }
536         else
537           output << n_("%{highlight}%{count} plugin failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
538                        "%{highlight}%{count} plugins failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
539                        @failed.length) %
540                     { :count => @failed.length, :highlight => Reverse,
541                       :bold => Bold, :command => "help failed plugins"}
542         end
543       end
544       output.join '; '
545     end
546
547     # return list of help topics (plugin names)
548     def helptopics
549       rv = status
550       @failures_shown = true
551       rv
552     end
553
554     def length
555       plugins.length
556     end
557
558     def core_length
559       core_modules.length
560     end
561
562     # return help for +topic+ (call associated plugin's help method)
563     def help(topic="")
564       case topic
565       when /fail(?:ed)?\s*plugins?.*(trace(?:back)?s?)?/
566         # debug "Failures: #{@failed.inspect}"
567         return _("no plugins failed to load") if @failed.empty?
568         return @failed.collect { |p|
569           _('%{highlight}%{plugin}%{highlight} in %{dir} failed with error %{exception}: %{reason}') % {
570               :highlight => Bold, :plugin => p[:name], :dir => p[:dir],
571               :exception => p[:reason].class, :reason => p[:reason],
572           } + if $1 && !p[:reason].backtrace.empty?
573                 _('at %{backtrace}') % {:backtrace => p[:reason].backtrace.join(', ')}
574               else
575                 ''
576               end
577         }.join("\n")
578       when /ignored?\s*plugins?/
579         return _('no plugins were ignored') if @ignored.empty?
580
581         tmp = Hash.new
582         @ignored.each do |p|
583           reason = p[:loaded] ? _('overruled by previous') : _(p[:reason].to_s)
584           ((tmp[p[:dir]] ||= Hash.new)[reason] ||= Array.new).push(p[:name])
585         end
586
587         return tmp.map do |dir, reasons|
588           # FIXME get rid of these string concatenations to make gettext easier
589           s = reasons.map { |r, list|
590             list.map { |_| _.sub(/\.rb$/, '') }.join(', ') + " (#{r})"
591           }.join('; ')
592           "in #{dir}: #{s}"
593         end.join('; ')
594       when /^(\S+)\s*(.*)$/
595         key = $1
596         params = $2
597
598         # Let's see if we can match a plugin by the given name
599         (core_modules + plugins).each { |p|
600           next unless p.name == key
601           begin
602             return p.help(key, params)
603           rescue Exception => err
604             #rescue TimeoutError, StandardError, NameError, SyntaxError => err
605             error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
606           end
607         }
608
609         # Nope, let's see if it's a command, and ask for help at the corresponding botmodule
610         k = key.to_sym
611         if commands.has_key?(k)
612           p = commands[k][:botmodule]
613           begin
614             return p.help(key, params)
615           rescue Exception => err
616             #rescue TimeoutError, StandardError, NameError, SyntaxError => err
617             error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
618           end
619         end
620       end
621       return false
622     end
623
624     # see if each plugin handles +method+, and if so, call it, passing
625     # +message+ as a parameter
626     def delegate(method, *args)
627       # debug "Delegating #{method.inspect}"
628       ret = Array.new
629       [core_modules, plugins].each { |pl|
630         pl.each {|p|
631           if(p.respond_to? method)
632             begin
633               # debug "#{p.botmodule_class} #{p.name} responds"
634               ret.push p.send(method, *args)
635             rescue Exception => err
636               raise if err.kind_of?(SystemExit)
637               error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
638               raise if err.kind_of?(BDB::Fatal)
639             end
640           end
641         }
642       }
643       return ret
644       # debug "Finished delegating #{method.inspect}"
645     end
646
647     # see if we have a plugin that wants to handle this message, if so, pass
648     # it to the plugin and return true, otherwise false
649     def privmsg(m)
650       # debug "Delegating privmsg #{m.message.inspect} from #{m.source} to #{m.replyto} with pluginkey #{m.plugin.inspect}"
651       return unless m.plugin
652       k = m.plugin.to_sym
653       if commands.has_key?(k)
654         p = commands[k][:botmodule]
655         a = commands[k][:auth]
656         # We check here for things that don't check themselves
657         # (e.g. mapped things)
658         # debug "Checking auth ..."
659         if a.nil? || @bot.auth.allow?(a, m.source, m.replyto)
660           # debug "Checking response ..."
661           if p.respond_to?("privmsg")
662             begin
663               # debug "#{p.botmodule_class} #{p.name} responds"
664               p.privmsg(m)
665             rescue Exception => err
666               raise if err.kind_of?(SystemExit)
667               error report_error("#{p.botmodule_class} #{p.name} privmsg() failed:", err)
668               raise if err.kind_of?(BDB::Fatal)
669             end
670             # debug "Successfully delegated #{m.message}"
671             return true
672           else
673             # debug "#{p.botmodule_class} #{p.name} is registered, but it doesn't respond to privmsg()"
674           end
675         else
676           # debug "#{p.botmodule_class} #{p.name} is registered, but #{m.source} isn't allowed to call #{m.plugin.inspect} on #{m.replyto}"
677         end
678       end
679       # debug "Finished delegating privmsg with key #{m.plugin.inspect}" + ( pl.empty? ? "" : " to #{pl.values.first[:botmodule].botmodule_class}s" )
680       return false
681       # debug "Finished delegating privmsg with key #{m.plugin.inspect}"
682     end
683   end
684
685   # Returns the only PluginManagerClass instance
686   def Plugins.manager
687     return PluginManagerClass.instance
688   end
689
690 end
691 end