TODO don’t write unchanged config to disk
[six.git] / pluginbase.rb
blob0f4d778fa2f7135df09bdc6f7785649c83504e55
3 # CyBot plugin root class.
6 class PluginBase
8   # Command flags.
9   CmdFlag_Normal    = 0   # Normal command.
10   CmdFlag_Channel   = 1   # Channel-bound command.
11   CmdFlag_Server    = 2   # Server-bound command.
12   CmdFlag_Multiple  = 4   # Command exists in multiple plugins (exclusive flag).
14   # Override this to do weird stuff :-p.
15   def PluginBase.shared_instance
16     @instance ||= self.new
17   end
19   # Force a new instance to be created. Used when loading.
20   def PluginBase.instance
21     @instance = self.new
22   end
24   # Reload support. Called on the _instance_.
25   # Before: Called just before reload would take place. Returning a string will disallow
26   #         the reload with that as a reason.
27   # After:  Called right after the reload. Returning true will create a new instance for
28   #         the plugin. Remeber to save in that case, since the new instance will invoke
29   #         load, as usual.
31   def before_reload(forced = false)
32     nil
33   end
34   def after_reload(forced = false)
35     false
36   end
38   # Called when a reload fails due to compile errors. Return a string to use as error,
39   # or nil for the default.
40   def reload_failed(exception)
41   end
43   # Access to stuff.
44   attr_reader :commands, :hooks, :brief_help
45   attr_accessor :sensitive_command
47   # Register a command.
48   def register_command(name, method_name, flags, help_map)
49     cmd = [flags, method(method_name.to_sym), self, help_map && help_map[name.to_sym]]
50     @commands[name] = cmd
51     if (c = $commands[name])
52       if c[0] != CmdFlag_Multiple
53         $commands[name] = (c = [CmdFlag_Multiple, c])
54       end
55       c << cmd
56     elsif (p = $plugins[name])
57       $commands[name] = [CmdFlag_Multiple, cmd]
58     else
59       $commands[name] = cmd
60     end
61   end
63   # Register commands for the given plugin instance.
64   def register_commands
66     # Step 1: Register new commands.
67     help_map = self.class.help_map
68     self.class.instance_methods(false).each do |name|
70       if name =~ /^cmd_(.*)$/
71         register_command($1, name, CmdFlag_Normal, help_map)
72       elsif name =~ /^serv_(.*)$/
73         register_command($1, name, CmdFlag_Server, help_map)
74       elsif name =~ /^chan_(.*)$/
75         register_command($1, name, CmdFlag_Channel, help_map)
76       elsif name =~ /^hook_(.*)$/
77         hook = $1.to_sym
78         h = [method(name.to_sym), hook, self]
79         hook_list = $hooks[hook] || ($hooks[hook] = [])
80         @hooks << h
81         hook_list << h
82       end
83     end
85     # Step 2: Fix-up old commands to avoid ambiguity.
86     if @commands.length > 0 and (c = $commands[name]) and c[0] != CmdFlag_Multiple
87       $commands[name] = [CmdFlag_Multiple, c]
88     end
90     # Step 3: Clean up.
91     self.class.help_map = nil
93   end
95   # We need to be able to call this.
96   class << self
97     public :remove_method
98   end
100   # Unregister commands and hooks for this plugin. Override to do dispose stuff.
101   def unregister
103     # Commands...
104     klass = self.class
105     @commands.each do |k,v|
107       # Remove method.
108       name = case v[0]
109         when CmdFlag_Normal:  'cmd_'
110         when CmdFlag_Channel: 'chan_'
111         when CmdFlag_Server:  'serv_'
112       end + k.to_s
113       klass.remove_method name.to_sym
115       # Remove from global maps.
116       if (cmd = $commands[k])
117         if cmd[0] != CmdFlag_Multiple
118           $commands.delete(k) if cmd[2].class == klass
119         else
120           j = cmd.each_with_index do |c, i|
121             next unless i > 0
122             break i if c[2].class == klass
123           end
124           cmd.delete_at(j) if j != cmd
125         end
126       end
128     end
130     # Hooks...
131     @hooks.each do |h|
132       klass.remove_method "hook_#{h[1]}".to_sym
133       if (hook = $hooks[h[1]])
134         hook.delete_if { |e| e[2].class == klass }
135       end
136     end
138     # Clean up.
139     @commands = {}
140     @hooks = []
142   end
144   # Only override this if you know what you're doing.
145   def name
146     @name ||= self.class.name.downcase
147   end
149   # Remember to call this to get your commands regged.
150   def initialize
151     @commands = {}
152     @hooks = []
153     @brief_help = "#{name.capitalize} plugin." unless @brief_help
154     register_commands
155     load
156   end
158   # Return the relative filename for this plugin.
159   def file_name(name = nil)
160     dir = "data/#{self.class.name.downcase}"
161     dir << '/' if name and name[0] != ?/
162     "#{dir}#{name}"
163   end
165   # Open or create a plugin data-file. Can accept a block.
166   def open_file(name, mode = 'r', &block)
167                 # Check that data exists
168                 Dir.mkdir('data') unless File.directory?('data')
169                 
170     path = "data/#{self.class.name.downcase}"
171     Dir.mkdir(path) unless File.directory?(path)
172     File.open("#{path}/#{name}", mode, &block)
173   end
175   # Load/save plugin-state. Override to do anything here.
176   # These are called on init and before the bot quits.
177   def load
178   end
179   def save
180   end
182   # Command help generator.
183   class << self
184     def help(cmd, help)
185       hm = @help_map || (@help_map = {})
186       hm[cmd] = help
187     end
188     attr_accessor :help_map
189   end
191   # Command parser wrapper generator.
192   # Argument types:
193   #   nick          A nick name.
194   #   channel       A channel the bot is on, or automatically
195   #                 set to the channel the command is called from.
196   #   anychannel    Arbitrary channel name.
197   #   integer       An integer.
198   #   text          Any text, possibly empty. Must be last argument.
199   #   text_ne       Any (non-empty) text. Ditto.
200   #
201   def PluginBase.parse(name, *args)
203     # No args, nothing to do.
204     return unless args.length > 0
206     # Alias old method.
207     new_name = '__parse_' + name.to_s
208     alias_method(new_name.to_sym, name.to_sym)
210     # And put in our new one.
211     code = <<-EOS
212       def #{name.to_s}(irc, line)
213         #{new_name}(irc, *parse(irc, line, #{args.map {|a| a.kind_of?(String) ?
214           ('"' + a + '"') : (':' + a.to_s)}.join(', ')}))
215       end
216     EOS
217 #    Kernel.puts "Genrated code: " + code
218     class_eval code
220   end
222   # Argument parse error.
223   class ParseError < Exception
224     def initialize(msg, arg_num, usage, format)
225       super("Argument #{arg_num + 1}: #{msg}")
226       @usage  = usage
227       @format = format
228     end
229     def usage
230       @usage ? @usage : (@format.map { |f|
231          case f
232          when :nick:         '<nick name>'
233          when :channel:      '[bot channel]'
234          when :anychannel:   '<channel>'
235          when :integer:      '<integer>'
236          when :text:         '[string]'
237          when :text_ne:      '<string>'
238          end
239       }.join(' '))
240     end
241   end
243   # Argument parser.
244   def parse(irc, line, *forms)
245     usage = forms[0].kind_of?(String) ? forms.delete_at(0) : nil
246     args = []
247     line ||= ''
248     forms.length.times do |i|
249       f = forms[i]
251       # Text. Last argument.
252       if f == :text or f == :text_ne
253         line = line.strip if line
254         if f == :text_ne and (!line or line.length == 0)
255           raise ParseError.new('Non-empty string expected.', i, usage, forms)
256         end
257         args << (line ? line : '')
258         break
260       # Current- or bot-channel.
261       elsif f == :channel
262         if (c = irc.channel)
263           args << c
264         else
265           if line: word, line = line.split(' ', 2)
266           else word = nil end
267           unless word and (c = irc.server.channels[word])
268             raise ParseError.new('Bot-channel name expected.', i, usage, forms)
269           end
270           args << c
271         end
273       # Other tokens.
274       else
275         if line: word, line = line.split(' ', 2)
276         else word = nil end
277         unless word and word.length > 0
278           raise ParseError.new('Not enough arguments.', i, usage, forms)
279         end
280         case f
282         # Nick name.  FIXME?
283         when :nick
284           args << word
286         # Channel name.
287         when :anychannel
288           unless word and word.length > 0 and (word[0] == ?# or word[0] == ?&)
289             raise ParseError.new('Channel name expected.', i, usage, forms)
290           end
291           args << word
293         # Integer.
294         when :integer
295           begin
296             int = Integer(word)
297             args << int
298           rescue ArgumentError
299             raise ParseError.new('Integer expected.', i, usage, forms)
300           end
302         end
303       end
305     end
306     args
307   end
309   # Date formatter.
310   WeekDays = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']
311   def human_date(ts)
312     days, now = ts.to_i / (24*60*60), Time.now.to_i / (24*60*60)
313     if days == now:         "at #{ts.strftime('%H:%M')}"
314     elsif days + 1 == now:  "yesterday at #{ts.strftime('%H:%M')}"
315     elsif days + 6 <= now:  "#{WeekDays[ts.wday]} at #{ts.strftime('%H:%M')}"
316     else                    "on #{ts.strftime('%m-%d %H:%M')}"
317     end
318   end
320   # Evaluate a setting (or settings) on channel and server levels, with optional
321   # global level provided by the passed block.
322   def setting_cs(irc, name = nil)
323     a = nil
324     if (s = $config["servers/#{irc.server.name}"])
325       if (c = s['channels']) and (c = c[irc.channel.name])
326         a = name ? c[name] : yield(c)
327       end
328       if a.nil?
329         a = name ? s[name] : yield(s)
330       end
331     end
332     if a.nil? and block_given?
333       a = name ? yield : yield(nil)
334     end
335     a
336   end
339   # ---------------------------------------------------------------------------
340   # Time duration formatter.
341   # ---------------------------------------------------------------------------
343   X_TimeWords = %w{ one two three four five six seven eight nine ten eleven twelve } # thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty
345   X_TimeUnits = [
346     [ "year",    365   * 60 * 60 * 24 ],
347     [ "month",    30.4 * 60 * 60 * 24 ],
348     [ "week",      7   * 60 * 60 * 24 ],
349     [ "day",      24   * 60 * 60      ],
350     [ "hour",     60   * 60           ],
351     [ "minute",   60                  ],
352     [ "second",    1                  ],
353   ]
355   X_TimePrefixes = { :greater => "almost ", :less => "about " }
357   def _round_time(seconds, unit_size)
358     count = seconds.to_f / unit_size
359     case count % 1.0
360       when 0.0:      [ count.to_i,  :exact   ]
361       when 0.0..0.3: [ count.floor, :less    ]
362       when 0.3..0.7: [ count.to_i,  :trunc   ]
363       when 0.7..1.0: [ count.ceil,  :greater ]
364     end
365   end
367   def _format_time(count, unit_name)
368     number = count < X_TimeWords.length ? X_TimeWords[count-1] : count.to_s
369     unit = unit_name + (count > 1 ? "s" : "")
370     return number + " " + unit
371   end
373   def seconds_to_s_fuzzy(seconds)
374     return "zero seconds" if seconds == 0
375     X_TimeUnits.each_index do |i|
376       unit_name, unit_size = X_TimeUnits[i]
378       number_of = seconds.to_f / unit_size
379       next if number_of <= 0.7
381       cnt, action = _round_time(seconds, unit_size)
382       if action == :trunc
383         sub_unit_name, size = X_TimeUnits[i+1]
384         sub_cnt = ((seconds % unit_size).to_f / size).round
385         return "#{_format_time cnt, unit_name} and #{_format_time sub_cnt, sub_unit_name}"
386       else
387         return "#{X_TimePrefixes[action]}#{_format_time cnt, unit_name}"
388       end
389     end
390   end
392   def seconds_to_s_exact(seconds)
393     s = seconds % 60
394     m = (seconds /= 60) % 60
395     h = (seconds /= 60) % 24
396     d = (seconds /= 24)
397     out = []
398     out << "#{d}d" if d > 0
399     out << "#{h}h" if h > 0
400     out << "#{m}m" if m > 0
401     out << "#{s}s" if s > 0
402     out.length > 0 ? out.join(' ') : '0s'
403   end
405   def seconds_to_s(seconds, irc = nil)
406     if irc and (u = $user.get_data(irc.from.nick, irc.server.name)) and !(u['fuzzy-time'] == false)
407       seconds_to_s_fuzzy(seconds)
408     else
409       seconds_to_s_exact(seconds)
410     end
411   end