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