]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - lib/rbot/plugins.rb
Whitespace/comment cleanup. Use =begin / =end for very large comments.
[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     end
303
304     # return list of help topics (plugin names)
305     def helptopics
306       # Active plugins first
307       if(@@plugins.length > 0)
308         list = " [#{length} plugin#{'s' if length > 1}: " + @@plugins.values.uniq.collect{|p| p.name}.sort.join(", ")
309       else
310         list = " [no plugins active"
311       end
312       # Ignored plugins next
313       list << "; #{Underline}#{@ignored.length} plugin#{'s' if @ignored.length > 1} ignored#{Underline}: use #{Bold}help ignored plugins#{Bold} to see why" unless @ignored.empty?
314       # Failed plugins next
315       list << "; #{Reverse}#{@failed.length} plugin#{'s' if @failed.length > 1} failed to load#{Reverse}: use #{Bold}help failed plugins#{Bold} to see why" unless @failed.empty?
316       list << "]"
317       return list
318     end
319
320     def length
321       @@plugins.values.uniq.length
322     end
323
324     # return help for +topic+ (call associated plugin's help method)
325     def help(topic="")
326       case topic
327       when /fail(?:ed)?\s*plugins?.*(trace(?:back)?s?)?/
328         # debug "Failures: #{@failed.inspect}"
329         return "no plugins failed to load" if @failed.empty?
330         return (@failed.inject(Array.new) { |list, p|
331           list << "#{Bold}#{p[:name]}#{Bold} in #{p[:dir]} failed"
332           list << "with error #{p[:reason].class}: #{p[:reason]}"
333           list << "at #{p[:reason].backtrace.join(', ')}" if $1 and not p[:reason].backtrace.empty?
334           list
335         }).join("\n")
336       when /ignored?\s*plugins?/
337         return "no plugins were ignored" if @ignored.empty?
338         return (@ignored.inject(Array.new) { |list, p|
339           case p[:reason]
340           when :loaded
341             list << "#{p[:name]} in #{p[:dir]} (overruled by previous)"
342           else
343             list << "#{p[:name]} in #{p[:dir]} (#{p[:reason].to_s})"
344           end
345           list
346         }).join(", ")
347       when /^(\S+)\s*(.*)$/
348         key = $1
349         params = $2
350         if(@@plugins.has_key?(key))
351           begin
352             return @@plugins[key].help(key, params)
353           rescue Exception => err
354             #rescue TimeoutError, StandardError, NameError, SyntaxError => err
355             error "plugin #{@@plugins[key].name} help() failed: #{err.class}: #{err}"
356             error err.backtrace.join("\n")
357           end
358         else
359           return false
360         end
361       end
362     end
363
364     # see if each plugin handles +method+, and if so, call it, passing
365     # +message+ as a parameter
366     def delegate(method, *args)
367       @@plugins.values.uniq.each {|p|
368         if(p.respond_to? method)
369           begin
370             p.send method, *args
371           rescue Exception => err
372             #rescue TimeoutError, StandardError, NameError, SyntaxError => err
373             error "plugin #{p.name} #{method}() failed: #{err.class}: #{err}"
374             error err.backtrace.join("\n")
375           end
376         end
377       }
378     end
379
380     # see if we have a plugin that wants to handle this message, if so, pass
381     # it to the plugin and return true, otherwise false
382     def privmsg(m)
383       return unless(m.plugin)
384       if (@@plugins.has_key?(m.plugin) &&
385           @@plugins[m.plugin].respond_to?("privmsg") &&
386           @@bot.auth.allow?(m.plugin, m.source, m.replyto))
387         begin
388           @@plugins[m.plugin].privmsg(m)
389         rescue Exception => err
390           #rescue TimeoutError, StandardError, NameError, SyntaxError => err
391           error "plugin #{@@plugins[m.plugin].name} privmsg() failed: #{err.class}: #{err}"
392           error err.backtrace.join("\n")
393         end
394         return true
395       end
396       return false
397     end
398   end
399
400 end
401 end