]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - lib/rbot/plugins.rb
First step towards the new modularized core framework
[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_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     # initialise your bot module. Always call super if you override this method,
102     # as important variables are set up for you
103     def initialize
104       @bot = Plugins.pluginmanager.bot
105       @botmodule_triggers = Array.new
106       @handler = MessageMapper.new(self)
107       @registry = BotRegistryAccessor.new(@bot, self.class.to_s.gsub(/^.*::/, ""))
108     end
109
110     def flush_registry
111       # debug "Flushing #{@registry}"
112       @registry.flush
113     end
114
115     def cleanup
116       # debug "Closing #{@registry}"
117       @registry.close
118     end
119
120     def handle(m)
121       @handler.handle(m)
122     end
123
124     def map(*args)
125       @handler.map(*args)
126       # register this map
127       name = @handler.last.items[0]
128       self.register name
129       unless self.respond_to?('privmsg')
130         def self.privmsg(m)
131           handle(m)
132         end
133       end
134     end
135
136     def map!(*args)
137       @handler.map(*args)
138       # register this map
139       name = @handler.last.items[0]
140       self.register name, {:hidden => true}
141       unless self.respond_to?('privmsg')
142         def self.privmsg(m)
143           handle(m)
144         end
145       end
146     end
147
148     # return an identifier for this plugin, defaults to a list of the message
149     # prefixes handled (used for error messages etc)
150     def name
151       self.class.downcase.sub(/(plugin)?$/,"")
152     end
153
154     # return a help string for your module. for complex modules, you may wish
155     # to break your help into topics, and return a list of available topics if
156     # +topic+ is nil. +plugin+ is passed containing the matching prefix for
157     # this message - if your plugin handles multiple prefixes, make sure you
158     # return the correct help for the prefix requested
159     def help(plugin, topic)
160       "no help"
161     end
162
163     # register the plugin as a handler for messages prefixed +name+
164     # this can be called multiple times for a plugin to handle multiple
165     # message prefixes
166     def register(name, kl, opts={})
167       raise ArgumentError, "Third argument must be a hash!" unless opts.kind_of?(Hash)
168       return if Plugins.pluginmanager.botmodules[kl].has_key?(name)
169       Plugins.pluginmanager.botmodules[kl][name] = self
170       @botmodule_triggers << name unless opts.fetch(:hidden, false)
171     end
172
173     # default usage method provided as a utility for simple plugins. The
174     # MessageMapper uses 'usage' as its default fallback method.
175     def usage(m, params = {})
176       m.reply "incorrect usage, ask for help using '#{@bot.nick}: help #{m.plugin}'"
177     end
178
179   end
180
181   class CoreBotModule < BotModule
182     def register(name, opts={})
183       raise ArgumentError, "Second argument must be a hash!" unless opts.kind_of?(Hash)
184       super(name, :core, opts)
185     end
186   end
187
188   class Plugin < BotModule
189     def register(name, opts={})
190       raise ArgumentError, "Second argument must be a hash!" unless opts.kind_of?(Hash)
191       super(name, :plugin, opts)
192     end
193   end
194
195   # class to manage multiple plugins and delegate messages to them for
196   # handling
197   class PluginManagerClass
198     include Singleton
199     attr_reader :bot
200     attr_reader :botmodules
201
202     def initialize
203       bot_associate(nil)
204     end
205
206     # Associate with bot _bot_
207     def bot_associate(bot)
208       @botmodules = {
209         :core => Hash.new,
210         :plugin => Hash.new
211       }
212
213       # associated IrcBot class
214       @bot = bot
215     end
216
217     # Returns a hash of the registered message prefixes and associated
218     # plugins
219     def plugins
220       @botmodules[:plugin]
221     end
222
223     # Returns a hash of the registered message prefixes and associated
224     # core modules
225     def core_modules
226       @botmodules[:core]
227     end
228
229     # Makes a string of error _err_ by adding text _str_
230     def report_error(str, err)
231       ([str, err.inspect] + err.backtrace).join("\n")
232     end
233
234     # This method is the one that actually loads a module from the
235     # file _fname_
236     #
237     # _desc_ is a simple description of what we are loading (plugin/botmodule/whatever)
238     #
239     # It returns the Symbol :loaded on success, and an Exception
240     # on failure
241     #
242     def load_botmodule_file(fname, desc=nil)
243       # create a new, anonymous module to "house" the plugin
244       # the idea here is to prevent namespace pollution. perhaps there
245       # is another way?
246       plugin_module = Module.new
247
248       desc = desc.to_s + " " if desc
249       begin
250         plugin_string = IO.readlines(fname).join("")
251         debug "loading #{desc}#{fname}"
252         plugin_module.module_eval(plugin_string, fname)
253         return :loaded
254       rescue Exception => err
255         # rescue TimeoutError, StandardError, NameError, LoadError, SyntaxError => err
256         warning report_error("#{desc}#{fname} load failed", err)
257         bt = err.backtrace.select { |line|
258           line.match(/^(\(eval\)|#{fname}):\d+/)
259         }
260         bt.map! { |el|
261           el.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
262             "#{fname}#{$1}#{$3}"
263           }
264         }
265         msg = err.to_str.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
266           "#{fname}#{$1}#{$3}"
267         }
268         newerr = err.class.new(msg)
269         newerr.set_backtrace(bt)
270         return newerr
271       end
272     end
273     private :load_botmodule_file
274
275     # Load core botmodules
276     def load_core(dir)
277       # TODO FIXME should this be hardcoded?
278       if(FileTest.directory?(dir))
279         d = Dir.new(dir)
280         d.sort.each { |file|
281           next unless(file =~ /[^.]\.rb$/)
282
283           did_it = load_botmodule_file("#{dir}/#{file}", "core module")
284           case did_it
285           when Symbol
286             # debug "loaded core botmodule #{dir}/#{file}"
287           when Exception
288             raise "failed to load core botmodule #{dir}/#{file}!"
289           end
290         }
291       end
292     end
293
294     # dirlist:: array of directories to scan (in order) for plugins
295     #
296     # create a new plugin handler, scanning for plugins in +dirlist+
297     def load_plugins(dirlist)
298       @dirs = dirlist
299       scan
300     end
301
302     # load plugins from pre-assigned list of directories
303     def scan
304       @failed = Array.new
305       @ignored = Array.new
306       processed = Hash.new
307
308       @bot.config['plugins.blacklist'].each { |p|
309         pn = p + ".rb"
310         processed[pn.intern] = :blacklisted
311       }
312
313       dirs = Array.new
314       # TODO FIXME should this be hardcoded?
315       dirs << Config::datadir + "/plugins"
316       dirs += @dirs
317       dirs.reverse.each {|dir|
318         if(FileTest.directory?(dir))
319           d = Dir.new(dir)
320           d.sort.each {|file|
321
322             next if(file =~ /^\./)
323
324             if processed.has_key?(file.intern)
325               @ignored << {:name => file, :dir => dir, :reason => processed[file.intern]}
326               next
327             end
328
329             if(file =~ /^(.+\.rb)\.disabled$/)
330               # GB: Do we want to do this? This means that a disabled plugin in a directory
331               #     will disable in all subsequent directories. This was probably meant
332               #     to be used before plugins.blacklist was implemented, so I think
333               #     we don't need this anymore
334               processed[$1.intern] = :disabled
335               @ignored << {:name => $1, :dir => dir, :reason => processed[$1.intern]}
336               next
337             end
338
339             next unless(file =~ /\.rb$/)
340
341             did_it = load_botmodule_file("#{dir}/#{file}", "plugin")
342             case did_it
343             when Symbol
344               processed[file.intern] = did_it
345             when Exception
346               @failed <<  { :name => file, :dir => dir, :reason => did_it }
347             end
348
349           }
350         end
351       }
352     end
353
354     # call the save method for each active plugin
355     def save
356       delegate 'flush_registry'
357       delegate 'save'
358     end
359
360     # call the cleanup method for each active plugin
361     def cleanup
362       delegate 'cleanup'
363     end
364
365     # drop all plugins and rescan plugins on disk
366     # calls save and cleanup for each plugin before dropping them
367     def rescan
368       save
369       cleanup
370       plugins.clear
371       scan
372     end
373
374     def status(short=false)
375       # Active plugins first
376       if(self.length > 0)
377         list = "#{self.length} plugin#{'s' if length > 1}"
378         if short
379           list << " loaded"
380         else
381           list << ": " + @@plugins.values.uniq.collect{|p| p.name}.sort.join(", ")
382         end
383       else
384         list = "no plugins active"
385       end
386       # Ignored plugins next
387       unless @ignored.empty?
388         list << "; #{Underline}#{@ignored.length} plugin#{'s' if @ignored.length > 1} ignored#{Underline}"
389         list << ": use #{Bold}help ignored plugins#{Bold} to see why" unless short
390       end
391       # Failed plugins next
392       unless @failed.empty?
393         list << "; #{Reverse}#{@failed.length} plugin#{'s' if @failed.length > 1} failed to load#{Reverse}"
394         list << ": use #{Bold}help failed plugins#{Bold} to see why" unless short
395       end
396       list
397     end
398
399     # return list of help topics (plugin names)
400     def helptopics
401       return " [#{status}]"
402     end
403
404     def length
405       plugins.values.uniq.length
406     end
407
408     # return help for +topic+ (call associated plugin's help method)
409     def help(topic="")
410       case topic
411       when /fail(?:ed)?\s*plugins?.*(trace(?:back)?s?)?/
412         # debug "Failures: #{@failed.inspect}"
413         return "no plugins failed to load" if @failed.empty?
414         return (@failed.inject(Array.new) { |list, p|
415           list << "#{Bold}#{p[:name]}#{Bold} in #{p[:dir]} failed"
416           list << "with error #{p[:reason].class}: #{p[:reason]}"
417           list << "at #{p[:reason].backtrace.join(', ')}" if $1 and not p[:reason].backtrace.empty?
418           list
419         }).join("\n")
420       when /ignored?\s*plugins?/
421         return "no plugins were ignored" if @ignored.empty?
422         return (@ignored.inject(Array.new) { |list, p|
423           case p[:reason]
424           when :loaded
425             list << "#{p[:name]} in #{p[:dir]} (overruled by previous)"
426           else
427             list << "#{p[:name]} in #{p[:dir]} (#{p[:reason].to_s})"
428           end
429           list
430         }).join(", ")
431       when /^(\S+)\s*(.*)$/
432         key = $1
433         params = $2
434         if(@@plugins.has_key?(key))
435           begin
436             return @@plugins[key].help(key, params)
437           rescue Exception => err
438             #rescue TimeoutError, StandardError, NameError, SyntaxError => err
439             error report_error("plugin #{@@plugins[key].name} help() failed:", err)
440           end
441         else
442           return false
443         end
444       end
445     end
446
447     # see if each plugin handles +method+, and if so, call it, passing
448     # +message+ as a parameter
449     def delegate(method, *args)
450       [core_modules, plugins].each { |pl|
451         pl.values.uniq.each {|p|
452           if(p.respond_to? method)
453             begin
454               p.send method, *args
455             rescue Exception => err
456               #rescue TimeoutError, StandardError, NameError, SyntaxError => err
457               error report_error("plugin #{p.name} #{method}() failed:", err)
458             end
459           end
460         }
461       }
462     end
463
464     # see if we have a plugin that wants to handle this message, if so, pass
465     # it to the plugin and return true, otherwise false
466     def privmsg(m)
467       [core_modules, plugins].each { |pl|
468         return unless(m.plugin)
469         if (pl.has_key?(m.plugin) &&
470           pl[m.plugin].respond_to?("privmsg") &&
471           @bot.auth.allow?(m.plugin, m.source, m.replyto))
472           begin
473             pl[m.plugin].privmsg(m)
474           rescue BDB::Fatal => err
475             error error_report("plugin #{pl[m.plugin].name} privmsg() failed:", err)
476             raise
477           rescue Exception => err
478             #rescue TimeoutError, StandardError, NameError, SyntaxError => err
479             error "plugin #{pl[m.plugin].name} privmsg() failed: #{err.class}: #{err}\n#{error err.backtrace.join("\n")}"
480           end
481           return true
482         end
483         return false
484       }
485     end
486   end
487
488   # Returns the only PluginManagerClass instance
489   def Plugins.pluginmanager
490     return PluginManagerClass.instance
491   end
492
493 end
494 end