factoids plugin: mark factoids as changed after factfile import
[rbot] / lib / rbot / config.rb
1 require 'singleton'
2
3 require 'yaml'
4
5 unless YAML.respond_to?(:load_file)
6   def YAML.load_file( filepath )
7     File.open( filepath ) do |f|
8       YAML::load( f )
9     end
10   end
11 end
12
13
14 module Irc
15
16 class Bot
17 module Config
18   class Value
19     # allow the definition order to be preserved so that sorting by
20     # definition order is possible. The Wizard does this to allow
21     # the :wizard questions to be in a sensible order.
22     @@order = 0
23     attr_reader :type
24     attr_reader :desc
25     attr_reader :key
26     attr_reader :wizard
27     attr_reader :requires_restart
28     attr_reader :requires_rescan
29     attr_reader :order
30     attr_reader :manager
31     attr_reader :auth_path
32     def initialize(key, params)
33       @manager = Config.manager
34       # Keys must be in the form 'module.name'.
35       # They will be internally passed around as symbols,
36       # but we accept them both in string and symbol form.
37       unless key.to_s =~ /^.+\..+$/
38         raise ArgumentError,"key must be of the form 'module.name'"
39       end
40       @order = @@order
41       @@order += 1
42       @key = key.to_sym
43       if params.has_key? :default
44         @default = params[:default]
45       else
46         @default = false
47       end
48       @desc = params[:desc]
49       @type = params[:type] || String
50       @on_change = params[:on_change]
51       @validate = params[:validate]
52       @wizard = params[:wizard]
53       @requires_restart = params[:requires_restart]
54       @requires_rescan = params[:requires_rescan]
55       @auth_path = "config::key::#{key.sub('.','::')}"
56     end
57     def default
58       if @default.instance_of?(Proc)
59         @default.call
60       else
61         @default
62       end
63     end
64     def get
65       return @manager.config[@key] if @manager.config.has_key?(@key)
66       return @default
67     end
68     alias :value :get
69     def set(value, on_change = true)
70       @manager.config[@key] = value
71       @manager.changed = true
72       @on_change.call(@manager.bot, value) if on_change && @on_change
73     end
74     def unset
75       @manager.config.delete(@key)
76       @manager.changed = true
77       @on_change.call(@manager.bot, value) if @on_change
78     end
79
80     # set string will raise ArgumentErrors on failed parse/validate
81     def set_string(string, on_change = true)
82       value = parse string
83       if validate value
84         set value, on_change
85       else
86         raise ArgumentError, "invalid value: #{string}"
87       end
88     end
89
90     # override this. the default will work for strings only
91     def parse(string)
92       string
93     end
94
95     def to_s
96       get.to_s
97     end
98
99     protected
100     def validate(val, validator  = @validate)
101       case validator
102       when false, nil
103         return true
104       when Proc
105         return validator.call(val)
106       when Regexp
107         raise ArgumentError, "validation via Regexp only supported for strings!" unless String === val
108         return validator.match(val)
109       else
110         raise ArgumentError, "validation type #{validator.class} not supported"
111       end
112     end
113   end
114
115   class StringValue < Value
116   end
117
118   class BooleanValue < Value
119     def parse(string)
120       return true if string == "true"
121       return false if string == "false"
122       if string =~ /^-?\d+$/
123         return string.to_i != 0
124       end
125       raise ArgumentError, "#{string} does not match either 'true' or 'false', and it's not an integer either"
126     end
127     def get
128       r = super
129       if r.kind_of?(Integer)
130         return r != 0
131       else
132         return r
133       end
134     end
135   end
136
137   class IntegerValue < Value
138     def parse(string)
139       return 1 if string == "true"
140       return 0 if string == "false"
141       raise ArgumentError, "not an integer: #{string}" unless string =~ /^-?\d+$/
142       string.to_i
143     end
144     def get
145       r = super
146       if r.kind_of?(Integer)
147         return r
148       else
149         return r ? 1 : 0
150       end
151     end
152   end
153
154   class FloatValue < Value
155     def parse(string)
156       raise ArgumentError, "not a float #{string}" unless string =~ /^-?[\d.]+$/
157       string.to_f
158     end
159   end
160
161   class ArrayValue < Value
162     def initialize(key, params)
163       super
164       @validate_item = params[:validate_item]
165       @validate ||= Proc.new do |v|
166         !v.find { |i| !validate_item(i) }
167       end
168     end
169
170     def validate_item(item)
171       validate(item, @validate_item)
172     end
173
174     def parse(string)
175       string.split(/,\s+/)
176     end
177     def to_s
178       get.join(", ")
179     end
180     def add(val)
181       newval = self.get.dup
182       unless newval.include? val
183         newval << val
184         validate_item(val) or raise ArgumentError, "invalid item: #{val}"
185         validate(newval) or raise ArgumentError, "invalid value: #{newval.to_s}"
186         set(newval)
187       end
188     end
189     def rm(val)
190       curval = self.get
191       raise ArgumentError, "value #{val} not present" unless curval.include?(val)
192       set(curval - [val])
193     end
194   end
195
196   class EnumValue < Value
197     def initialize(key, params)
198       super
199       @values = params[:values]
200     end
201     def values
202       if @values.instance_of?(Proc)
203         return @values.call(@manager.bot)
204       else
205         return @values
206       end
207     end
208     def parse(string)
209       unless values.include?(string)
210         raise ArgumentError, "invalid value #{string}, allowed values are: " + values.join(", ")
211       end
212       string
213     end
214     def desc
215       _("%{desc} [valid values are: %{values}]") % {:desc => @desc, :values => values.join(', ')}
216     end
217   end
218
219   # container for bot configuration
220   class ManagerClass
221
222     include Singleton
223
224     attr_reader :bot
225     attr_reader :items
226     attr_reader :config
227     attr_accessor :changed
228
229     def initialize
230       bot_associate(nil,true)
231     end
232
233     def reset_config
234       @items = Hash.new
235       @config = Hash.new(false)
236     end
237
238     # Associate with bot _bot_
239     def bot_associate(bot, reset=false)
240       reset_config if reset
241       @bot = bot
242       return unless @bot
243
244       @changed = false
245       if(File.exist?("#{@bot.botclass}/conf.yaml"))
246         begin
247           newconfig = YAML::load_file("#{@bot.botclass}/conf.yaml")
248           newconfig.each { |key, val|
249             @config[key.to_sym] = val
250           }
251           return
252         rescue
253           error "failed to read conf.yaml: #{$!}"
254         end
255       end
256       # if we got here, we need to run the first-run wizard
257       Wizard.new(@bot).run
258       # save newly created config
259       @changed = true
260       save
261     end
262
263     def register(item)
264       unless item.kind_of?(Value)
265         raise ArgumentError,"item must be an Irc::Bot::Config::Value"
266       end
267       @items[item.key] = item
268     end
269
270     # currently we store values in a hash but this could be changed in the
271     # future. We use hash semantics, however.
272     # components that register their config keys and setup defaults are
273     # supported via []
274     def [](key)
275       # return @items[key].value if @items.has_key?(key)
276       return @items[key.to_sym].value if @items.has_key?(key.to_sym)
277       # try to still support unregistered lookups
278       # but warn about them
279       #      if @config.has_key?(key)
280       #        warning "Unregistered lookup #{key.inspect}"
281       #        return @config[key]
282       #      end
283       if @config.has_key?(key.to_sym)
284         warning _("Unregistered lookup #{key.to_sym.inspect}")
285         return @config[key.to_sym]
286       end
287       return false
288     end
289
290     # TODO should I implement this via Value or leave it direct?
291     #    def []=(key, value)
292     #    end
293
294     # pass everything else through to the hash
295     def method_missing(method, *args, &block)
296       return @config.send(method, *args, &block)
297     end
298
299     # write current configuration to #{botclass}/conf.yaml
300     def save
301       if not @changed
302         debug "Not writing conf.yaml (unchanged)"
303         return
304       end
305       begin
306         debug "Writing new conf.yaml ..."
307         File.open("#{@bot.botclass}/conf.yaml.new", "w") do |file|
308           savehash = {}
309           @config.each { |key, val|
310             savehash[key.to_s] = val
311           }
312           file.puts savehash.to_yaml
313         end
314         debug "Officializing conf.yaml ..."
315         File.rename("#{@bot.botclass}/conf.yaml.new",
316                     "#{@bot.botclass}/conf.yaml")
317         @changed = false
318       rescue => e
319         error "failed to write configuration file conf.yaml! #{$!}"
320         error "#{e.class}: #{e}"
321         error e.backtrace.join("\n")
322       end
323     end
324   end
325
326   # Returns the only Irc::Bot::Config::ManagerClass
327   #
328   def Config.manager
329     return ManagerClass.instance
330   end
331
332   # Register a config value
333   def Config.register(item)
334     Config.manager.register(item)
335   end
336
337   class Wizard
338     def initialize(bot)
339       @bot = bot
340       @manager = Config.manager
341       @questions = @manager.items.values.find_all {|i| i.wizard }
342     end
343
344     def run()
345       puts _("First time rbot configuration wizard")
346       puts "===================================="
347       puts _("This is the first time you have run rbot with a config directory of: #{@bot.botclass}")
348       puts _("This wizard will ask you a few questions to get you started.")
349       puts _("The rest of rbot's configuration can be manipulated via IRC once rbot is connected and you are auth'd.")
350       puts "-----------------------------------"
351
352       return unless @questions
353       @questions.sort{|a,b| a.order <=> b.order }.each do |q|
354         puts _(q.desc)
355         begin
356           print q.key.to_s + " [#{q.to_s}]: "
357           response = STDIN.gets
358           response.chop!
359           unless response.empty?
360             q.set_string response, false
361           end
362           puts _("configured #{q.key} => #{q.to_s}")
363           puts "-----------------------------------"
364         rescue ArgumentError => e
365           puts _("failed to set #{q.key}: #{e.message}")
366           retry
367         end
368       end
369     end
370   end
371
372 end
373 end
374 end