integrated ruby-gettext
[rbot] / 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 '%{command}'") % {:command => "#{@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       output = []
475       if self.core_length > 0
476         if short
477           output << n_("%{count} core module loaded", "%{count} core modules loaded",
478                     self.core_length) % {:count => self.core_length}
479         else
480           output <<  n_("%{count} core module: %{list}",
481                      "%{count} core modules: %{list}", self.core_length) %
482                      { :count => self.core_length,
483                        :list => core_modules.collect{ |p| p.name}.sort.join(", ") }
484         end
485       else
486         output << _("no core botmodules loaded")
487       end
488       # Active plugins first
489       if(self.length > 0)
490         if short
491           output << n_("%{count} plugin loaded", "%{count} plugins loaded",
492                        self.length) % {:count => self.length}
493         else
494           output << n_("%{count} plugin: %{list}",
495                        "%{count} plugins: %{list}", self.length) %
496                    { :count => self.length,
497                      :list => core_modules.collect{ |p| p.name}.sort.join(", ") }
498         end
499       else
500         output << "no plugins active"
501       end
502       # Ignored plugins next
503       unless @ignored.empty? or @failures_shown
504         if short
505           output << n_("%{highlight}%{count} plugin ignored%{highlight}",
506                        "%{highlight}%{count} plugins ignored%{highlight}",
507                        @ignored.length) %
508                     { :count => @ignored.length, :highlight => Underline }
509         else
510           output << n_("%{highlight}%{count} plugin ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
511                        "%{highlight}%{count} plugins ignored%{highlight}: use %{bold}%{command}%{bold} to see why",
512                        @ignored.length) %
513                     { :count => @ignored.length, :highlight => Underline,
514                       :bold => Bold, :command => "help ignored plugins"}
515         end
516       end
517       # Failed plugins next
518       unless @failed.empty? or @failures_shown
519         if short
520           output << n_("%{highlight}%{count} plugin failed to load%{highlight}",
521                        "%{highlight}%{count} plugins failed to load%{highlight}",
522                        @ignored.length) %
523                     { :count => @ignored.length, :highlight => Reverse }
524         else
525           output << n_("%{highlight}%{count} plugin failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
526                        "%{highlight}%{count} plugins failed to load%{highlight}: use %{bold}%{command}%{bold} to see why",
527                        @ignored.length) %
528                     { :count => @ignored.length, :highlight => Reverse,
529                       :bold => Bold, :command => "#{Bold}help failed plugins#{Bold}"}
530         end
531       end
532       output.join '; '
533     end
534
535     # return list of help topics (plugin names)
536     def helptopics
537       rv = status
538       @failures_shown = true
539       rv
540     end
541
542     def length
543       plugins.length
544     end
545
546     def core_length
547       core_modules.length
548     end
549
550     # return help for +topic+ (call associated plugin's help method)
551     def help(topic="")
552       case topic
553       when /fail(?:ed)?\s*plugins?.*(trace(?:back)?s?)?/
554         # debug "Failures: #{@failed.inspect}"
555         return _("no plugins failed to load") if @failed.empty?
556         return @failed.collect { |p|
557           _('%{highlight}%{plugin}%{highlight} in %{dir}failed with error %{exception}: %{reason}') % {
558               :highlight => Bold, :plugin => p[:name], :dir => p[:dir],
559               :exception => p[:reason].class, :reason => p[:reason],
560           } + if $1 && !p[:reason].backtrace.empty?
561                 _('at %{backtrace}') % {:backtrace => p[:reason].backtrace.join(', ')}
562               else
563                 ''
564               end
565         }.join("\n")
566       when /ignored?\s*plugins?/
567         return _('no plugins were ignored') if @ignored.empty?
568
569         tmp = Hash.new
570         @ignored.each do |p|
571           reason = p[:loaded] ? _('overruled by previous') : _(p[:reason].to_s)
572           ((tmp[p[:dir]] ||= Hash.new)[reason] ||= Array.new).push(p[:name])
573         end
574
575         return tmp.map do |dir, reasons|
576           # FIXME get rid of these string concatenations to make gettext easier
577           s = reasons.map { |r, list|
578             list.map { |_| _.sub(/\.rb$/, '') }.join(', ') + " (#{r})"
579           }.join('; ')
580           "in #{dir}: #{s}"
581         end.join('; ')
582       when /^(\S+)\s*(.*)$/
583         key = $1
584         params = $2
585
586         # Let's see if we can match a plugin by the given name
587         (core_modules + plugins).each { |p|
588           next unless p.name == key
589           begin
590             return p.help(key, params)
591           rescue Exception => err
592             #rescue TimeoutError, StandardError, NameError, SyntaxError => err
593             error report_error("#{p.botmodule_class} #{p.name} help() failed:", err)
594           end
595         }
596
597         # Nope, let's see if it's a command, and ask for help at the corresponding botmodule
598         k = key.to_sym
599         if commands.has_key?(k)
600           p = commands[k][:botmodule]
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         end
608       end
609       return false
610     end
611
612     # see if each plugin handles +method+, and if so, call it, passing
613     # +message+ as a parameter
614     def delegate(method, *args)
615       # debug "Delegating #{method.inspect}"
616       ret = Array.new
617       [core_modules, plugins].each { |pl|
618         pl.each {|p|
619           if(p.respond_to? method)
620             begin
621               # debug "#{p.botmodule_class} #{p.name} responds"
622               ret.push p.send(method, *args)
623             rescue Exception => err
624               raise if err.kind_of?(SystemExit)
625               error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
626               raise if err.kind_of?(BDB::Fatal)
627             end
628           end
629         }
630       }
631       return ret
632       # debug "Finished delegating #{method.inspect}"
633     end
634
635     # see if we have a plugin that wants to handle this message, if so, pass
636     # it to the plugin and return true, otherwise false
637     def privmsg(m)
638       # debug "Delegating privmsg #{m.message.inspect} from #{m.source} to #{m.replyto} with pluginkey #{m.plugin.inspect}"
639       return unless m.plugin
640       k = m.plugin.to_sym
641       if commands.has_key?(k)
642         p = commands[k][:botmodule]
643         a = commands[k][:auth]
644         # We check here for things that don't check themselves
645         # (e.g. mapped things)
646         # debug "Checking auth ..."
647         if a.nil? || @bot.auth.allow?(a, m.source, m.replyto)
648           # debug "Checking response ..."
649           if p.respond_to?("privmsg")
650             begin
651               # debug "#{p.botmodule_class} #{p.name} responds"
652               p.privmsg(m)
653             rescue Exception => err
654               raise if err.kind_of?(SystemExit)
655               error report_error("#{p.botmodule_class} #{p.name} privmsg() failed:", err)
656               raise if err.kind_of?(BDB::Fatal)
657             end
658             # debug "Successfully delegated #{m.message}"
659             return true
660           else
661             # debug "#{p.botmodule_class} #{p.name} is registered, but it doesn't respond to privmsg()"
662           end
663         else
664           # debug "#{p.botmodule_class} #{p.name} is registered, but #{m.source} isn't allowed to call #{m.plugin.inspect} on #{m.replyto}"
665         end
666       end
667       # debug "Finished delegating privmsg with key #{m.plugin.inspect}" + ( pl.empty? ? "" : " to #{pl.values.first[:botmodule].botmodule_class}s" )
668       return false
669       # debug "Finished delegating privmsg with key #{m.plugin.inspect}"
670     end
671   end
672
673   # Returns the only PluginManagerClass instance
674   def Plugins.manager
675     return PluginManagerClass.instance
676   end
677
678 end
679 end