New modular framework is in place. Nothing works until core/auth.rb is done, though
[rbot] / lib / rbot / plugins.rb
1 require 'singleton'
2
3 module Irc
4     BotConfig.register BotConfigArrayValue.new('plugins.blacklist',
5       :default => [], :wizard => false, :requires_restart => true,
6       :desc => "Plugins that should not be loaded")
7 module Plugins
8   require 'rbot/messagemapper'
9
10 =begin
11   base class for all rbot plugins
12   certain methods will be called if they are provided, if you define one of
13   the following methods, it will be called as appropriate:
14
15   map(template, options)::
16   map!(template, options)::
17      map is the new, cleaner way to respond to specific message formats
18      without littering your plugin code with regexps. The difference
19      between map and map! is that map! will not register the new command
20      as an alternative name for the plugin.
21
22      Examples:
23
24        plugin.map 'karmastats', :action => 'karma_stats'
25
26        # while in the plugin...
27        def karma_stats(m, params)
28          m.reply "..."
29        end
30
31        # the default action is the first component
32        plugin.map 'karma'
33
34        # attributes can be pulled out of the match string
35        plugin.map 'karma for :key'
36        plugin.map 'karma :key'
37
38        # while in the plugin...
39        def karma(m, params)
40          item = params[:key]
41          m.reply 'karma for #{item}'
42        end
43
44        # you can setup defaults, to make parameters optional
45        plugin.map 'karma :key', :defaults => {:key => 'defaultvalue'}
46
47        # the default auth check is also against the first component
48        # but that can be changed
49        plugin.map 'karmastats', :auth => 'karma'
50
51        # maps can be restricted to public or private message:
52        plugin.map 'karmastats', :private false,
53        plugin.map 'karmastats', :public false,
54      end
55
56   listen(UserMessage)::
57                          Called for all messages of any type. To
58                          differentiate them, use message.kind_of? It'll be
59                          either a PrivMessage, NoticeMessage, KickMessage,
60                          QuitMessage, PartMessage, JoinMessage, NickMessage,
61                          etc.
62
63   privmsg(PrivMessage)::
64                          called for a PRIVMSG if the first word matches one
65                          the plugin register()d for. Use m.plugin to get
66                          that word and m.params for the rest of the message,
67                          if applicable.
68
69   kick(KickMessage)::
70                          Called when a user (or the bot) is kicked from a
71                          channel the bot is in.
72
73   join(JoinMessage)::
74                          Called when a user (or the bot) joins a channel
75
76   part(PartMessage)::
77                          Called when a user (or the bot) parts a channel
78
79   quit(QuitMessage)::
80                          Called when a user (or the bot) quits IRC
81
82   nick(NickMessage)::
83                          Called when a user (or the bot) changes Nick
84   topic(TopicMessage)::
85                          Called when a user (or the bot) changes a channel
86                          topic
87
88   connect()::            Called when a server is joined successfully, but
89                          before autojoin channels are joined (no params)
90
91   save::                 Called when you are required to save your plugin's
92                          state, if you maintain data between sessions
93
94   cleanup::              called before your plugin is "unloaded", prior to a
95                          plugin reload or bot quit - close any open
96                          files/connections or flush caches here
97 =end
98
99   class BotModule
100     attr_reader :bot   # the associated bot
101     attr_reader :botmodule_class # the botmodule class (:coremodule or :plugin)
102
103     # initialise your bot module. Always call super if you override this method,
104     # as important variables are set up for you
105     def initialize(kl)
106       @manager = Plugins::pluginmanager
107       @bot = @manager.bot
108
109       @botmodule_class = kl.to_sym
110       @botmodule_triggers = Array.new
111
112       @handler = MessageMapper.new(self)
113       @registry = BotRegistryAccessor.new(@bot, self.class.to_s.gsub(/^.*::/, ""))
114
115       @manager.add_botmodule(kl, self)
116     end
117
118     def flush_registry
119       # debug "Flushing #{@registry}"
120       @registry.flush
121     end
122
123     def cleanup
124       # debug "Closing #{@registry}"
125       @registry.close
126     end
127
128     def handle(m)
129       @handler.handle(m)
130     end
131
132     def map(*args)
133       @handler.map(*args)
134       # register this map
135       name = @handler.last.items[0]
136       self.register name
137       unless self.respond_to?('privmsg')
138         def self.privmsg(m)
139           handle(m)
140         end
141       end
142     end
143
144     def map!(*args)
145       @handler.map(*args)
146       # register this map
147       name = @handler.last.items[0]
148       self.register name, {:hidden => true}
149       unless self.respond_to?('privmsg')
150         def self.privmsg(m)
151           handle(m)
152         end
153       end
154     end
155
156     # return an identifier for this plugin, defaults to a list of the message
157     # prefixes handled (used for error messages etc)
158     def name
159       self.class.to_s.downcase.sub(/^#<module:.*?>::/,"").sub(/(plugin)?$/,"")
160     end
161
162     # just calls name
163     def to_s
164       name
165     end
166
167     # return a help string for your module. for complex modules, you may wish
168     # to break your help into topics, and return a list of available topics if
169     # +topic+ is nil. +plugin+ is passed containing the matching prefix for
170     # this message - if your plugin handles multiple prefixes, make sure you
171     # return the correct help for the prefix requested
172     def help(plugin, topic)
173       "no help"
174     end
175
176     # register the plugin as a handler for messages prefixed +name+
177     # this can be called multiple times for a plugin to handle multiple
178     # message prefixes
179     def register(name, opts={})
180       raise ArgumentError, "Second argument must be a hash!" unless opts.kind_of?(Hash)
181       return if @manager.knows?(name, @botmodule_class)
182       @manager.register(name, @botmodule_class, self)
183       @botmodule_triggers << name unless opts.fetch(:hidden, false)
184     end
185
186     # default usage method provided as a utility for simple plugins. The
187     # MessageMapper uses 'usage' as its default fallback method.
188     def usage(m, params = {})
189       m.reply "incorrect usage, ask for help using '#{@bot.nick}: help #{m.plugin}'"
190     end
191
192   end
193
194   class CoreBotModule < BotModule
195     def initialize
196       super(:coremodule)
197     end
198   end
199
200   class Plugin < BotModule
201     def initialize
202       super(:plugin)
203     end
204   end
205
206   # Singleton to manage multiple plugins and delegate messages to them for
207   # handling
208   class PluginManagerClass
209     include Singleton
210     attr_reader :bot
211     attr_reader :botmodules
212
213     def initialize
214       bot_associate(nil)
215
216       @dirs = []
217     end
218
219     # Reset lists of botmodules
220     def reset_botmodule_lists
221       @botmodules = {
222         :coremodule => [],
223         :plugin => []
224       }
225
226       @commandmappers = {
227         :coremodule => {},
228         :plugin => {}
229       }
230
231     end
232
233     # Associate with bot _bot_
234     def bot_associate(bot)
235       reset_botmodule_lists
236       @bot = bot
237     end
238
239     # Returns +true+ if _name_ is a known botmodule of class kl
240     def knows?(name, kl)
241       return @commandmappers[kl.to_sym].has_key?(name.to_sym)
242     end
243
244     # Returns +true+ if _name_ is a known botmodule of class kl
245     def register(name, kl, botmodule)
246       raise TypeError, "Third argument #{botmodule.inspect} is not of class BotModule" unless botmodule.class <= BotModule
247       @commandmappers[kl.to_sym][name.to_sym] = botmodule
248     end
249
250     def add_botmodule(kl, botmodule)
251       raise TypeError, "Second argument #{botmodule.inspect} is not of class BotModule" unless botmodule.class <= BotModule
252       raise "#{kl.to_s} #{botmodule.name} already registered!" if @botmodules[kl.to_sym].include?(botmodule)
253       @botmodules[kl.to_sym] << botmodule
254     end
255
256     # Returns an array of the loaded plugins
257     def core_modules
258       @botmodules[:coremodule]
259     end
260
261     # Returns an array of the loaded plugins
262     def plugins
263       @botmodules[:plugin]
264     end
265
266     # Returns a hash of the registered message prefixes and associated
267     # plugins
268     def plugin_commands
269       @commandmappers[:plugin]
270     end
271
272     # Returns a hash of the registered message prefixes and associated
273     # core modules
274     def core_commands
275       @commandmappers[:coremodule]
276     end
277
278     # Makes a string of error _err_ by adding text _str_
279     def report_error(str, err)
280       ([str, err.inspect] + err.backtrace).join("\n")
281     end
282
283     # This method is the one that actually loads a module from the
284     # file _fname_
285     #
286     # _desc_ is a simple description of what we are loading (plugin/botmodule/whatever)
287     #
288     # It returns the Symbol :loaded on success, and an Exception
289     # on failure
290     #
291     def load_botmodule_file(fname, desc=nil)
292       # create a new, anonymous module to "house" the plugin
293       # the idea here is to prevent namespace pollution. perhaps there
294       # is another way?
295       plugin_module = Module.new
296
297       desc = desc.to_s + " " if desc
298
299       begin
300         plugin_string = IO.readlines(fname).join("")
301         debug "loading #{desc}#{fname}"
302         plugin_module.module_eval(plugin_string, fname)
303         return :loaded
304       rescue Exception => err
305         # rescue TimeoutError, StandardError, NameError, LoadError, SyntaxError => err
306         warning report_error("#{desc}#{fname} load failed", err)
307         bt = err.backtrace.select { |line|
308           line.match(/^(\(eval\)|#{fname}):\d+/)
309         }
310         bt.map! { |el|
311           el.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
312             "#{fname}#{$1}#{$3}"
313           }
314         }
315         msg = err.to_str.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
316           "#{fname}#{$1}#{$3}"
317         }
318         newerr = err.class.new(msg)
319         newerr.set_backtrace(bt)
320         return newerr
321       end
322     end
323     private :load_botmodule_file
324
325     # add one or more directories to the list of directories to
326     # load botmodules from
327     #
328     def add_botmodule_dir(*dirlist)
329       @dirs += dirlist
330       debug "Botmodule loading path: #{@dirs.join(', ')}"
331     end
332
333     # load plugins from pre-assigned list of directories
334     def scan
335       @failed = Array.new
336       @ignored = Array.new
337       processed = Hash.new
338
339       @bot.config['plugins.blacklist'].each { |p|
340         pn = p + ".rb"
341         processed[pn.intern] = :blacklisted
342       }
343
344       dirs = @dirs
345       dirs.each {|dir|
346         if(FileTest.directory?(dir))
347           d = Dir.new(dir)
348           d.sort.each {|file|
349
350             next if(file =~ /^\./)
351
352             if processed.has_key?(file.intern)
353               @ignored << {:name => file, :dir => dir, :reason => processed[file.intern]}
354               next
355             end
356
357             if(file =~ /^(.+\.rb)\.disabled$/)
358               # GB: Do we want to do this? This means that a disabled plugin in a directory
359               #     will disable in all subsequent directories. This was probably meant
360               #     to be used before plugins.blacklist was implemented, so I think
361               #     we don't need this anymore
362               processed[$1.intern] = :disabled
363               @ignored << {:name => $1, :dir => dir, :reason => processed[$1.intern]}
364               next
365             end
366
367             next unless(file =~ /\.rb$/)
368
369             did_it = load_botmodule_file("#{dir}/#{file}", "plugin")
370             case did_it
371             when Symbol
372               processed[file.intern] = did_it
373             when Exception
374               @failed <<  { :name => file, :dir => dir, :reason => did_it }
375             end
376
377           }
378         end
379       }
380       debug "finished loading plugins: #{status(true)}"
381     end
382
383     # call the save method for each active plugin
384     def save
385       delegate 'flush_registry'
386       delegate 'save'
387     end
388
389     # call the cleanup method for each active plugin
390     def cleanup
391       delegate 'cleanup'
392       reset_botmodule_lists
393     end
394
395     # drop all plugins and rescan plugins on disk
396     # calls save and cleanup for each plugin before dropping them
397     def rescan
398       save
399       cleanup
400       scan
401     end
402
403     def status(short=false)
404       list = ""
405       if self.core_length > 0
406         list << "#{self.core_length} core module#{'s' if core_length > 1}"
407         if short
408           list << " loaded"
409         else
410           list << ": " + core_modules.collect{ |p| p.name}.sort.join(", ")
411         end
412       else
413         list << "no core botmodules loaded"
414       end
415       # Active plugins first
416       if(self.length > 0)
417         list << "; #{self.length} plugin#{'s' if length > 1}"
418         if short
419           list << " loaded"
420         else
421           list << ": " + plugins.collect{ |p| p.name}.sort.join(", ")
422         end
423       else
424         list << "no plugins active"
425       end
426       # Ignored plugins next
427       unless @ignored.empty?
428         list << "; #{Underline}#{@ignored.length} plugin#{'s' if @ignored.length > 1} ignored#{Underline}"
429         list << ": use #{Bold}help ignored plugins#{Bold} to see why" unless short
430       end
431       # Failed plugins next
432       unless @failed.empty?
433         list << "; #{Reverse}#{@failed.length} plugin#{'s' if @failed.length > 1} failed to load#{Reverse}"
434         list << ": use #{Bold}help failed plugins#{Bold} to see why" unless short
435       end
436       list
437     end
438
439     # return list of help topics (plugin names)
440     def helptopics
441       return " [#{status}]"
442     end
443
444     def length
445       plugins.length
446     end
447
448     def core_length
449       core_modules.length
450     end
451
452     # return help for +topic+ (call associated plugin's help method)
453     def help(topic="")
454       case topic
455       when /fail(?:ed)?\s*plugins?.*(trace(?:back)?s?)?/
456         # debug "Failures: #{@failed.inspect}"
457         return "no plugins failed to load" if @failed.empty?
458         return (@failed.inject(Array.new) { |list, p|
459           list << "#{Bold}#{p[:name]}#{Bold} in #{p[:dir]} failed"
460           list << "with error #{p[:reason].class}: #{p[:reason]}"
461           list << "at #{p[:reason].backtrace.join(', ')}" if $1 and not p[:reason].backtrace.empty?
462           list
463         }).join("\n")
464       when /ignored?\s*plugins?/
465         return "no plugins were ignored" if @ignored.empty?
466         return (@ignored.inject(Array.new) { |list, p|
467           case p[:reason]
468           when :loaded
469             list << "#{p[:name]} in #{p[:dir]} (overruled by previous)"
470           else
471             list << "#{p[:name]} in #{p[:dir]} (#{p[:reason].to_s})"
472           end
473           list
474         }).join(", ")
475       when /^(\S+)\s*(.*)$/
476         key = $1
477         params = $2
478         [core_commands, plugin_commands].each { |pl|
479           if(pl.has_key?(key))
480             begin
481               return pl[key].help(key, params)
482             rescue Exception => err
483               #rescue TimeoutError, StandardError, NameError, SyntaxError => err
484               error report_error("#{p.botmodule_class} #{plugins[key].name} help() failed:", err)
485             end
486           else
487             return false
488           end
489         }
490       end
491     end
492
493     # see if each plugin handles +method+, and if so, call it, passing
494     # +message+ as a parameter
495     def delegate(method, *args)
496       debug "Delegating #{method.inspect}"
497       [core_modules, plugins].each { |pl|
498         pl.each {|p|
499           if(p.respond_to? method)
500             begin
501               debug "#{p.botmodule_class} #{p.name} responds"
502               p.send method, *args
503             rescue Exception => err
504               error report_error("#{p.botmodule_class} #{p.name} #{method}() failed:", err)
505               raise if err.class <= BDB::Fatal
506             end
507           end
508         }
509       }
510       debug "Finished delegating #{method.inspect}"
511     end
512
513     # see if we have a plugin that wants to handle this message, if so, pass
514     # it to the plugin and return true, otherwise false
515     def privmsg(m)
516       debug "Delegating privmsg with key #{m.plugin}"
517       return unless m.plugin
518       begin
519         [core_commands, plugin_commands].each { |pl|
520           # We do it this way to skip creating spurious keys
521           # FIXME use fetch?
522           k = m.plugin.to_sym
523           if pl.has_key?(k)
524             p = pl[k]
525           else
526             p = nil
527           end
528           if p
529             # TODO This should probably be checked elsewhere
530             debug "Checking auth ..."
531             if @bot.auth.allow?(m.plugin, m.source, m.replyto)
532               debug "Checking response ..."
533               if p.respond_to?("privmsg")
534                 begin
535                   debug "#{p.botmodule_class} #{p.name} responds"
536                   p.privmsg(m)
537                 rescue Exception => err
538                   error report_error("#{p.botmodule_class} #{p.name} privmsg() failed:", err)
539                   raise if err.class <= BDB::Fatal
540                 end
541                 debug "Successfully delegated privmsg with key #{m.plugin}"
542                 return true
543               else
544                 debug "#{p.botmodule_class} #{p.name} is registered, but it doesn't respond to privmsgs"
545               end
546             else
547               debug "#{p.botmodule_class} #{p.name} is registered, but #{m.source} isn't allowed to use #{m.plugin} on #{m.replyto}"
548             end
549           else
550             debug "No #{pl.values.first.botmodule_class} registered #{m.plugin}" unless pl.empty?
551           end
552           debug "Finished delegating privmsg with key #{m.plugin}" + ( pl.empty? ? "" : " to #{pl.values.first.botmodule_class}s" )
553         }
554         return false
555       rescue Exception => e
556         error report_error("couldn't delegate #{m}", e)
557       end
558       debug "Finished delegating privmsg with key #{m.plugin}"
559     end
560   end
561
562   # Returns the only PluginManagerClass instance
563   def Plugins.pluginmanager
564     return PluginManagerClass.instance
565   end
566
567 end
568 end