]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - lib/rbot/plugins.rb
BDB::Fatal errors in plugins are now raised to toplevel; bdb lg_max increased to...
[user/henk/code/ruby/rbot.git] / lib / rbot / plugins.rb
1 module Irc
2     BotConfig.register BotConfigArrayValue.new('plugins.blacklist',
3       :default => [], :wizard => false, :requires_restart => true,
4       :desc => "Plugins that should not be loaded")
5 module Plugins
6   require 'rbot/messagemapper'
7
8 =begin
9   base class for all rbot plugins
10   certain methods will be called if they are provided, if you define one of
11   the following methods, it will be called as appropriate:
12
13   map(template, options)::
14   map!(template, options)::
15      map is the new, cleaner way to respond to specific message formats
16      without littering your plugin code with regexps. The difference
17      between map and map! is that map! will not register the new command
18      as an alternative name for the plugin.
19
20      Examples:
21
22        plugin.map 'karmastats', :action => 'karma_stats'
23
24        # while in the plugin...
25        def karma_stats(m, params)
26          m.reply "..."
27        end
28
29        # the default action is the first component
30        plugin.map 'karma'
31
32        # attributes can be pulled out of the match string
33        plugin.map 'karma for :key'
34        plugin.map 'karma :key'
35
36        # while in the plugin...
37        def karma(m, params)
38          item = params[:key]
39          m.reply 'karma for #{item}'
40        end
41
42        # you can setup defaults, to make parameters optional
43        plugin.map 'karma :key', :defaults => {:key => 'defaultvalue'}
44
45        # the default auth check is also against the first component
46        # but that can be changed
47        plugin.map 'karmastats', :auth => 'karma'
48
49        # maps can be restricted to public or private message:
50        plugin.map 'karmastats', :private false,
51        plugin.map 'karmastats', :public false,
52      end
53
54   listen(UserMessage)::
55                          Called for all messages of any type. To
56                          differentiate them, use message.kind_of? It'll be
57                          either a PrivMessage, NoticeMessage, KickMessage,
58                          QuitMessage, PartMessage, JoinMessage, NickMessage,
59                          etc.
60
61   privmsg(PrivMessage)::
62                          called for a PRIVMSG if the first word matches one
63                          the plugin register()d for. Use m.plugin to get
64                          that word and m.params for the rest of the message,
65                          if applicable.
66
67   kick(KickMessage)::
68                          Called when a user (or the bot) is kicked from a
69                          channel the bot is in.
70
71   join(JoinMessage)::
72                          Called when a user (or the bot) joins a channel
73
74   part(PartMessage)::
75                          Called when a user (or the bot) parts a channel
76
77   quit(QuitMessage)::
78                          Called when a user (or the bot) quits IRC
79
80   nick(NickMessage)::
81                          Called when a user (or the bot) changes Nick
82   topic(TopicMessage)::
83                          Called when a user (or the bot) changes a channel
84                          topic
85
86   connect()::            Called when a server is joined successfully, but
87                          before autojoin channels are joined (no params)
88
89   save::                 Called when you are required to save your plugin's
90                          state, if you maintain data between sessions
91
92   cleanup::              called before your plugin is "unloaded", prior to a
93                          plugin reload or bot quit - close any open
94                          files/connections or flush caches here
95 =end
96
97   class Plugin
98     attr_reader :bot   # the associated bot
99     # initialise your plugin. Always call super if you override this method,
100     # as important variables are set up for you
101     def initialize
102       @bot = Plugins.bot
103       @names = Array.new
104       @handler = MessageMapper.new(self)
105       @registry = BotRegistryAccessor.new(@bot, self.class.to_s.gsub(/^.*::/, ""))
106     end
107
108     def flush_registry
109       # debug "Flushing #{@registry}"
110       @registry.flush
111     end
112
113     def cleanup
114       # debug "Closing #{@registry}"
115       @registry.close
116     end
117
118     def handle(m)
119       @handler.handle(m)
120     end
121
122     def map(*args)
123       @handler.map(*args)
124       # register this map
125       name = @handler.last.items[0]
126       self.register name
127       unless self.respond_to?('privmsg')
128         def self.privmsg(m)
129           handle(m)
130         end
131       end
132     end
133
134     def map!(*args)
135       @handler.map(*args)
136       # register this map
137       name = @handler.last.items[0]
138       self.register name, {:hidden => true}
139       unless self.respond_to?('privmsg')
140         def self.privmsg(m)
141           handle(m)
142         end
143       end
144     end
145
146     # return an identifier for this plugin, defaults to a list of the message
147     # prefixes handled (used for error messages etc)
148     def name
149       @names.join("|")
150     end
151
152     # return a help string for your module. for complex modules, you may wish
153     # to break your help into topics, and return a list of available topics if
154     # +topic+ is nil. +plugin+ is passed containing the matching prefix for
155     # this message - if your plugin handles multiple prefixes, make sure you
156     # return the correct help for the prefix requested
157     def help(plugin, topic)
158       "no help"
159     end
160
161     # register the plugin as a handler for messages prefixed +name+
162     # this can be called multiple times for a plugin to handle multiple
163     # message prefixes
164     def register(name,opts={})
165       raise ArgumentError, "Second argument must be a hash!" unless opts.kind_of?(Hash)
166       return if Plugins.plugins.has_key?(name)
167       Plugins.plugins[name] = self
168       @names << name unless opts.fetch(:hidden, false)
169     end
170
171     # default usage method provided as a utility for simple plugins. The
172     # MessageMapper uses 'usage' as its default fallback method.
173     def usage(m, params = {})
174       m.reply "incorrect usage, ask for help using '#{@bot.nick}: help #{m.plugin}'"
175     end
176
177   end
178
179   # class to manage multiple plugins and delegate messages to them for
180   # handling
181   class Plugins
182     # hash of registered message prefixes and associated plugins
183     @@plugins = Hash.new
184     # associated IrcBot class
185     @@bot = nil
186
187     # bot::     associated IrcBot class
188     # dirlist:: array of directories to scan (in order) for plugins
189     #
190     # create a new plugin handler, scanning for plugins in +dirlist+
191     def initialize(bot, dirlist)
192       @@bot = bot
193       @dirs = dirlist
194       scan
195     end
196
197     # access to associated bot
198     def Plugins.bot
199       @@bot
200     end
201
202     # access to list of plugins
203     def Plugins.plugins
204       @@plugins
205     end
206
207     # load plugins from pre-assigned list of directories
208     def scan
209       @failed = Array.new
210       @ignored = Array.new
211       processed = Hash.new
212
213       @@bot.config['plugins.blacklist'].each { |p|
214         pn = p + ".rb"
215         processed[pn.intern] = :blacklisted
216       }
217
218       dirs = Array.new
219       dirs << Config::datadir + "/plugins"
220       dirs += @dirs
221       dirs.reverse.each {|dir|
222         if(FileTest.directory?(dir))
223           d = Dir.new(dir)
224           d.sort.each {|file|
225
226             next if(file =~ /^\./)
227
228             if processed.has_key?(file.intern)
229               @ignored << {:name => file, :dir => dir, :reason => processed[file.intern]}
230               next
231             end
232
233             if(file =~ /^(.+\.rb)\.disabled$/)
234               # GB: Do we want to do this? This means that a disabled plugin in a directory
235               #     will disable in all subsequent directories. This was probably meant
236               #     to be used before plugins.blacklist was implemented, so I think
237               #     we don't need this anymore
238               processed[$1.intern] = :disabled
239               @ignored << {:name => $1, :dir => dir, :reason => processed[$1.intern]}
240               next
241             end
242
243             next unless(file =~ /\.rb$/)
244
245             tmpfilename = "#{dir}/#{file}"
246
247             # create a new, anonymous module to "house" the plugin
248             # the idea here is to prevent namespace pollution. perhaps there
249             # is another way?
250             plugin_module = Module.new
251
252             begin
253               plugin_string = IO.readlines(tmpfilename).join("")
254               debug "loading plugin #{tmpfilename}"
255               plugin_module.module_eval(plugin_string, tmpfilename)
256               processed[file.intern] = :loaded
257             rescue Exception => err
258               # rescue TimeoutError, StandardError, NameError, LoadError, SyntaxError => err
259               warning "plugin #{tmpfilename} load failed\n" + err.inspect
260               debug err.backtrace.join("\n")
261               bt = err.backtrace.select { |line|
262                 line.match(/^(\(eval\)|#{tmpfilename}):\d+/)
263               }
264               bt.map! { |el|
265                 el.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
266                   "#{tmpfilename}#{$1}#{$3}"
267                 }
268               }
269               msg = err.to_str.gsub(/^\(eval\)(:\d+)(:in `.*')?(:.*)?/) { |m|
270                 "#{tmpfilename}#{$1}#{$3}"
271               }
272               newerr = err.class.new(msg)
273               newerr.set_backtrace(bt)
274               # debug "Simplified error: " << newerr.inspect
275               # debug newerr.backtrace.join("\n")
276               @failed << { :name => file, :dir => dir, :reason => newerr }
277               # debug "Failures: #{@failed.inspect}"
278             end
279           }
280         end
281       }
282     end
283
284     # call the save method for each active plugin
285     def save
286       delegate 'flush_registry'
287       delegate 'save'
288     end
289
290     # call the cleanup method for each active plugin
291     def cleanup
292       delegate 'cleanup'
293     end
294
295     # drop all plugins and rescan plugins on disk
296     # calls save and cleanup for each plugin before dropping them
297     def rescan
298       save
299       cleanup
300       @@plugins = Hash.new
301       scan
302
303     end
304
305     def status(short=false)
306       # Active plugins first
307       if(@@plugins.length > 0)
308         list = "#{length} plugin#{'s' if length > 1}"
309         if short
310           list << " loaded"
311         else
312           list << ": " + @@plugins.values.uniq.collect{|p| p.name}.sort.join(", ")
313         end
314       else
315         list = "no plugins active"
316       end
317       # Ignored plugins next
318       unless @ignored.empty?
319         list << "; #{Underline}#{@ignored.length} plugin#{'s' if @ignored.length > 1} ignored#{Underline}"
320         list << ": use #{Bold}help ignored plugins#{Bold} to see why" unless short
321       end
322       # Failed plugins next
323       unless @failed.empty?
324         list << "; #{Reverse}#{@failed.length} plugin#{'s' if @failed.length > 1} failed to load#{Reverse}"
325         list << ": use #{Bold}help failed plugins#{Bold} to see why" unless short
326       end
327       list
328     end
329
330     # return list of help topics (plugin names)
331     def helptopics
332       return " [#{status}]"
333     end
334
335     def length
336       @@plugins.values.uniq.length
337     end
338
339     # return help for +topic+ (call associated plugin's help method)
340     def help(topic="")
341       case topic
342       when /fail(?:ed)?\s*plugins?.*(trace(?:back)?s?)?/
343         # debug "Failures: #{@failed.inspect}"
344         return "no plugins failed to load" if @failed.empty?
345         return (@failed.inject(Array.new) { |list, p|
346           list << "#{Bold}#{p[:name]}#{Bold} in #{p[:dir]} failed"
347           list << "with error #{p[:reason].class}: #{p[:reason]}"
348           list << "at #{p[:reason].backtrace.join(', ')}" if $1 and not p[:reason].backtrace.empty?
349           list
350         }).join("\n")
351       when /ignored?\s*plugins?/
352         return "no plugins were ignored" if @ignored.empty?
353         return (@ignored.inject(Array.new) { |list, p|
354           case p[:reason]
355           when :loaded
356             list << "#{p[:name]} in #{p[:dir]} (overruled by previous)"
357           else
358             list << "#{p[:name]} in #{p[:dir]} (#{p[:reason].to_s})"
359           end
360           list
361         }).join(", ")
362       when /^(\S+)\s*(.*)$/
363         key = $1
364         params = $2
365         if(@@plugins.has_key?(key))
366           begin
367             return @@plugins[key].help(key, params)
368           rescue Exception => err
369             #rescue TimeoutError, StandardError, NameError, SyntaxError => err
370             error "plugin #{@@plugins[key].name} help() failed: #{err.class}: #{err}"
371             error err.backtrace.join("\n")
372           end
373         else
374           return false
375         end
376       end
377     end
378
379     # see if each plugin handles +method+, and if so, call it, passing
380     # +message+ as a parameter
381     def delegate(method, *args)
382       @@plugins.values.uniq.each {|p|
383         if(p.respond_to? method)
384           begin
385             p.send method, *args
386           rescue Exception => err
387             #rescue TimeoutError, StandardError, NameError, SyntaxError => err
388             error "plugin #{p.name} #{method}() failed: #{err.class}: #{err}"
389             error err.backtrace.join("\n")
390           end
391         end
392       }
393     end
394
395     # see if we have a plugin that wants to handle this message, if so, pass
396     # it to the plugin and return true, otherwise false
397     def privmsg(m)
398       return unless(m.plugin)
399       if (@@plugins.has_key?(m.plugin) &&
400           @@plugins[m.plugin].respond_to?("privmsg") &&
401           @@bot.auth.allow?(m.plugin, m.source, m.replyto))
402         begin
403           @@plugins[m.plugin].privmsg(m)
404         rescue BDB::Fatal => err
405           error "plugin #{@@plugins[m.plugin].name} privmsg() failed: #{err.class}: #{err}"
406           error err.backtrace.join("\n")
407           raise
408         rescue Exception => err
409           #rescue TimeoutError, StandardError, NameError, SyntaxError => err
410           error "plugin #{@@plugins[m.plugin].name} privmsg() failed: #{err.class}: #{err}"
411           error err.backtrace.join("\n")
412         end
413         return true
414       end
415       return false
416     end
417   end
418
419 end
420 end