colloquy 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (65) hide show
  1. checksums.yaml +15 -0
  2. data/README.md +160 -0
  3. data/TODO.md +256 -0
  4. data/bin/colloquy +14 -0
  5. data/examples/config/flows.yaml +22 -0
  6. data/examples/config/logger.yaml +2 -0
  7. data/examples/config/messages.yaml +3 -0
  8. data/examples/config/mysql.yaml +18 -0
  9. data/examples/config/redis.yaml +9 -0
  10. data/examples/config/scribe.yaml +2 -0
  11. data/examples/config/settings.yaml +2 -0
  12. data/examples/config/test.yaml +8 -0
  13. data/examples/config/urls.yaml +18 -0
  14. data/examples/flows/active_record_flow.rb +42 -0
  15. data/examples/flows/art_of_war_flow.rb +27 -0
  16. data/examples/flows/calculator_flow.rb +71 -0
  17. data/examples/flows/crossover_flow.rb +17 -0
  18. data/examples/flows/database_flow.rb +33 -0
  19. data/examples/flows/hangman_flow.rb +82 -0
  20. data/examples/flows/metadata_flow.rb +11 -0
  21. data/examples/flows/pagination_flow.rb +23 -0
  22. data/examples/flows/pass_flow.rb +29 -0
  23. data/examples/flows/prefix_menu_flow.rb +24 -0
  24. data/examples/flows/scribe_flow.rb +26 -0
  25. data/examples/flows/settings_flow.rb +23 -0
  26. data/examples/flows/special/special_redis_flow.rb +28 -0
  27. data/examples/flows/url_flow.rb +27 -0
  28. data/examples/log/renderer.log +198381 -0
  29. data/examples/log/urls.log +3269 -0
  30. data/examples/messages/active_record.yaml +2 -0
  31. data/examples/messages/art_of_war.yaml +1 -0
  32. data/examples/messages/calculator.yaml +2 -0
  33. data/examples/messages/database.yaml +1 -0
  34. data/examples/messages/hangman.yaml +0 -0
  35. data/examples/messages/prefix_menu.yaml +3 -0
  36. data/examples/models/activations.rb +5 -0
  37. data/lib/colloquy.rb +39 -0
  38. data/lib/colloquy/exceptions.rb +64 -0
  39. data/lib/colloquy/flow_parser.rb +315 -0
  40. data/lib/colloquy/flow_pool.rb +21 -0
  41. data/lib/colloquy/helpers.rb +15 -0
  42. data/lib/colloquy/helpers/mysql.rb +110 -0
  43. data/lib/colloquy/helpers/redis.rb +103 -0
  44. data/lib/colloquy/helpers/scribe.rb +103 -0
  45. data/lib/colloquy/helpers/settings.rb +111 -0
  46. data/lib/colloquy/helpers/url.rb +10 -0
  47. data/lib/colloquy/input.rb +14 -0
  48. data/lib/colloquy/logger.rb +11 -0
  49. data/lib/colloquy/menu.rb +128 -0
  50. data/lib/colloquy/message_builder.rb +54 -0
  51. data/lib/colloquy/node.rb +67 -0
  52. data/lib/colloquy/paginator.rb +59 -0
  53. data/lib/colloquy/paginator/menu.rb +79 -0
  54. data/lib/colloquy/paginator/prompt.rb +32 -0
  55. data/lib/colloquy/prompt.rb +35 -0
  56. data/lib/colloquy/renderer.rb +423 -0
  57. data/lib/colloquy/response.rb +4 -0
  58. data/lib/colloquy/runner.rb +93 -0
  59. data/lib/colloquy/server.rb +157 -0
  60. data/lib/colloquy/session_store.rb +46 -0
  61. data/lib/colloquy/session_store/memory.rb +14 -0
  62. data/lib/colloquy/session_store/redis.rb +24 -0
  63. data/lib/colloquy/simulator.rb +114 -0
  64. data/lib/colloquy/spec_helpers.rb +21 -0
  65. metadata +459 -0
@@ -0,0 +1,35 @@
1
+
2
+ class Colloquy::Prompt
3
+ include Colloquy::Paginator
4
+
5
+ attr_accessor :flow
6
+
7
+ def initialize(options = {})
8
+ @message = options[:message] || ''
9
+ @page = options[:page] || 1
10
+ @flow = options[:flow] || nil
11
+ end
12
+
13
+ def render(page = @page)
14
+ paginate unless @pages
15
+
16
+ execute_before_page_hook
17
+ "#{render_body(page)}"
18
+ end
19
+
20
+ def ==(string)
21
+ @message == string
22
+ end
23
+
24
+ def freeze
25
+ @message.freeze
26
+ end
27
+
28
+ def key(input)
29
+ if input.to_s == '1'
30
+ :more
31
+ else
32
+ :unknown
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,423 @@
1
+ require 'yaml'
2
+ require 'active_support/inflector'
3
+ require 'active_support/core_ext/hash'
4
+ require 'colloquy/logger'
5
+
6
+ class Colloquy::Renderer
7
+ DEFAULT_ERROR_MESSAGE = 'This service is not available at present. Please try again later!'
8
+
9
+ attr_reader :logger
10
+
11
+ # Extracts root path from options hash and creates a HashWithIndifferentAccess object using options.
12
+ #
13
+ # @param [Hash] options The options hash
14
+ def initialize(options = {})
15
+ Colloquy.root = options[:path_root] if options[:path_root]
16
+ @options = options.with_indifferent_access
17
+ end
18
+
19
+ # Initializes renderer components if given configuration is valid.
20
+ # The #configure it executes does lots of initializations and configurations. For details see the method.
21
+ def prepare!
22
+ configure if configuration_valid?
23
+ end
24
+
25
+ # This method is the only endpoint of Renderer used by Server#response. It receives flow_name, msisdn, session_id, input and metadata
26
+ # provided by Server and returns response after doing lots of stuff with the input.
27
+ #
28
+ # It makes a session key for each received call, fetches a flow instance corresponding to it (which is an instance
29
+ # of some class with module FlowParser mixed in), and pass all this to #apply!
30
+ #
31
+ # @return [Response] response The processed response
32
+ def apply(flow_name, msisdn, session_id, input = nil, metadata = {})
33
+ response = ''
34
+ flow_state = :notify
35
+
36
+ begin
37
+ session_key = make_session_key(msisdn, session_id)
38
+ state, session, flow = state_load(flow_name, session_key, metadata)
39
+
40
+ response = apply!(flow, state, session, session_key, flow_name, msisdn, session_id, input)
41
+ flow_state = @state[flow_name.to_sym][session_key][:flow_state].to_sym
42
+ rescue Exception => e
43
+ logger.error "Exception: #{e.inspect} in #{e.backtrace[0]} when processing: flow: #{flow_name}, msisdn: #{msisdn}, session_id: #{session_id}, input: #{input}"
44
+ logger.debug e.backtrace.inspect
45
+
46
+ begin
47
+ logger.debug 'Responding with default error message.'
48
+ flow_for_messages = @flows[flow_name.to_sym]
49
+
50
+ response = Colloquy::MessageBuilder.to_message(:error_unexpected, flow: flow_for_messages)
51
+ flow_state = :notify
52
+ rescue Exception => e
53
+ logger.error 'An error occured when we tried to render the error message, falling back to default error response'
54
+ logger.error "Exception: #{e.inspect} in #{e.backtrace[0]}"
55
+ logger.debug e.backtrace.inspect
56
+
57
+ response = DEFAULT_ERROR_MESSAGE
58
+ flow_state = :notify
59
+ end
60
+ end
61
+
62
+ # We construct a response object
63
+ response = Colloquy::Response.new(response)
64
+ response.flow_state = flow_state
65
+
66
+ response
67
+ end
68
+
69
+ def reload_messages!
70
+ @messages = {}
71
+
72
+ load_messages
73
+ end
74
+
75
+ def reload_flows!
76
+ @flows = {}
77
+
78
+ load_flows
79
+ load_messages
80
+ end
81
+
82
+ def reload_flow!(flow_name)
83
+ @flows[flow_name.to_sym] = nil
84
+ load_flow(flow_name)
85
+ load_messages_into_flow(flow_name)
86
+ end
87
+
88
+ def flow_exists?(flow_name)
89
+ @flows[flow_name.to_sym]
90
+ end
91
+
92
+ private
93
+
94
+ # Checks all configurations including flows, logger and messages.
95
+ # @return [true] if all are valid, raises an exception if not.
96
+ def configuration_valid?
97
+ @options[:path_config] = Colloquy.root.join('config')
98
+
99
+ configuration_directory_exists?
100
+
101
+ %W(flows logger messages).each do |file|
102
+ method_name = "#{file}_yaml_exists?".to_sym
103
+ send(method_name)
104
+
105
+ key = "path_#{file}_yaml".to_sym
106
+ @options[file.to_sym] = File.open(@options[key]) { |f| YAML.load(f.read) }
107
+ end
108
+
109
+ true
110
+ end
111
+
112
+ def configuration_directory_exists?
113
+ unless File.exists?(Colloquy.root) and
114
+ File.directory?(Colloquy.root) and
115
+ File.directory?(@options[:path_config])
116
+ raise Colloquy::ConfigurationFolderNotFound,
117
+ "Cannot find #{Colloquy.root} to load configuration from.\
118
+ Renderer needs a valid root path to read config/flows.yaml and config/logger.yaml"
119
+ end
120
+ end
121
+
122
+ # Does lots of initial setup.
123
+ # @private
124
+ def configure
125
+ initialize_logger
126
+
127
+ load_paths
128
+ load_flows
129
+ load_messages
130
+
131
+ set_maximum_message_length
132
+ set_maximum_unicode_length
133
+ set_flow_pool_size
134
+
135
+ initialize_session
136
+ initialize_state
137
+
138
+ load_flow_pool
139
+ end
140
+
141
+ def flows_yaml_exists?
142
+ @options[:path_flows_yaml] = @options[:path_config].join('flows.yaml')
143
+
144
+ unless @options[:path_flows_yaml].exist?
145
+ raise Colloquy::FlowConfigurationNotFound, "Cannot find flows yaml in #{@options[:path_flow_yaml]}"
146
+ end
147
+ end
148
+
149
+ def logger_yaml_exists?
150
+ @options[:path_logger_yaml] = @options[:path_config].join('logger.yaml')
151
+
152
+ unless @options[:path_logger_yaml].exist?
153
+ raise Colloquy::LoggerConfigurationNotFound, "Cannot find flows yaml in #{@options[:path_flow_yaml]}"
154
+ end
155
+ end
156
+
157
+ def messages_yaml_exists?
158
+ @options[:path_messages_yaml] = @options[:path_config].join('messages.yaml')
159
+
160
+ unless @options[:path_messages_yaml].exist?
161
+ raise Colloquy::MessagesConfigurationNotFound, "Cannot find messages yaml in #{@options[:path_messages_yaml]}"
162
+ end
163
+ end
164
+
165
+ # Infer messages yaml path from flow name
166
+ # @param [String] flow_name Flow name
167
+ # @return [Pathname] Path to messages yaml corresponding to flow name
168
+ def messages_file_name(flow_name)
169
+ Colloquy.root.join('messages', "#{flow_name}.yaml")
170
+ end
171
+
172
+ # Configure and create logger instance
173
+ def initialize_logger
174
+ logger_file = Pathname.new(Colloquy.root.join(@options[:logger][:path]))
175
+ log_level = if @options[:verbose]
176
+ :DEBUG
177
+ else
178
+ @options[:logger][:log_level]
179
+ end.upcase.to_sym
180
+
181
+ raise Colloquy::LogDirectoryNotPresent unless logger_file
182
+
183
+ @logger = Colloquy::Logger.new(logger_file)
184
+ @logger.info 'Renderer starting up...'
185
+ @logger.info "Log level is #{log_level}"
186
+ @logger.level = ActiveSupport::Logger::Severity.const_get(log_level)
187
+
188
+ # Set this up as a logger available at the root
189
+ Colloquy.logger = @logger
190
+ end
191
+
192
+ # Load load_paths configured in flows yaml into ruby load path
193
+ def load_paths
194
+ @options[:flows][:load_paths].each do |path|
195
+ flow_path = Pathname.new(path)
196
+ $: << if flow_path.relative?
197
+ Colloquy.root.join(flow_path)
198
+ else
199
+ flow_path
200
+ end.realpath.to_s
201
+ end
202
+ end
203
+
204
+ # Load all active flow specific messages into flow
205
+ def load_messages
206
+ @options[:flows][:active].each do |flow_entry|
207
+ load_messages_into_flow(flow_entry)
208
+ end
209
+ end
210
+
211
+ # Load flow specific messages into flow
212
+ # @param [String] flow_entry Entry in flows yaml
213
+ def load_messages_into_flow(flow_entry)
214
+ flow_messages = @options[:messages]
215
+ _, flow_name, _ = extract_flow_components_from_entry(flow_entry)
216
+
217
+ if messages_file_name(flow_name).exist?
218
+ messages = File.open(messages_file_name(flow_name)) { |f| YAML.load(f.read) }
219
+ messages ||= {}
220
+ messages = messages.with_indifferent_access
221
+
222
+ flow_messages = flow_messages.merge(messages)
223
+ end
224
+
225
+ @flows[flow_name.to_sym].messages = flow_messages
226
+ end
227
+
228
+ # Create a hash with all active flows mapped to their instances
229
+ # @return [Hash] A Hash with all flows and instances
230
+ def load_flows
231
+ @flows = {}
232
+
233
+ @options[:flows][:active].each do |flow_entry|
234
+ load_flow(flow_entry)
235
+ end
236
+
237
+ @flows
238
+ end
239
+
240
+ def load_flow_pool
241
+ @options[:flows][:active].each do |flow_entry|
242
+ _, flow_name, flow_class = extract_flow_components_from_entry(flow_entry)
243
+ messages = @flows[flow_name.to_sym].messages
244
+ Colloquy::Renderer::FlowPool.create_flows(flow_name, flow_class, @flow_pool_size, logger: @logger, messages: messages)
245
+ end
246
+ end
247
+
248
+ # Sets maximum messages length, restricted to 160 for by telecom Provider
249
+ def set_maximum_message_length
250
+ Colloquy.maximum_message_length = (@options[:flows][:maximum_message_length] || 160).to_i
251
+ end
252
+
253
+ # Sets maximum unicode length, restricted to 70 as unicode uses two bytes per character
254
+ def set_maximum_unicode_length
255
+ Colloquy.maximum_unicode_length = (@options[:flows][:maximum_unicode_length] || 70).to_i
256
+ end
257
+
258
+ def set_flow_pool_size
259
+ @flow_pool_size = (@options[:flows][:flow_pool_size] || 50).to_i
260
+ end
261
+
262
+ # This is where the input is actually passed on to FlowParser#apply for processing.
263
+ #
264
+ # This method taps into flow object and injects the current state, session and headers
265
+ # into it. This modifed object is then passed to FlowParser#apply
266
+ # @return [Response] The response returned from FlowParser#apply
267
+ def apply!(flow, state, session, session_key, flow_name, msisdn, session_id, input = nil)
268
+ # set flow state and session correctly, reset all nodes
269
+ flow = prime_flow(flow, state, session, flow_name, msisdn, session_id, input)
270
+
271
+ # apply and get the response
272
+ response = flow.apply(input)
273
+
274
+ # store the state and session from the applied flow
275
+ state_reset_from_flow(flow, flow_name, session_key)
276
+ session_reset_from_flow(flow, flow_name, session_key)
277
+
278
+ # return the response
279
+ response
280
+ rescue Colloquy::SwitchFlowJump => e
281
+ # add flow back into flow pool before switching to new flow
282
+ Colloquy::Renderer::FlowPool.add_flow(flow.flow_name, flow)
283
+ session_reset_from_flow(flow, flow_name, session_key)
284
+ session_switch_flow(session_key, flow_name, e.payload[:flow], e.payload[:node])
285
+ state, session, flow = state_load(flow_name, session_key)
286
+
287
+ retry
288
+ rescue Colloquy::FlowPoolEmpty
289
+ flow = nil #explicitly marked as nil so that its not added back into the pool
290
+ raise
291
+ ensure
292
+ # add flow back into flow pool
293
+ Colloquy::Renderer::FlowPool.add_flow(flow.flow_name, flow) if flow
294
+ end
295
+
296
+ def prime_flow(flow, state, session, flow_name, msisdn, session_id, input)
297
+ flow.tap do |f|
298
+ f.state = state
299
+ f.session = session
300
+ f.headers = {
301
+ flow_name: flow_name,
302
+ msisdn: msisdn,
303
+ session_id: session_id,
304
+ input: input,
305
+ page: (state[:page] || 1),
306
+ metadata: (state[:metadata] || {})
307
+ }
308
+ end
309
+ end
310
+
311
+ def state_reset_from_flow(flow, flow_name, session_key)
312
+ @state[flow_name.to_sym][session_key] = flow.send(:state) # state is private
313
+ end
314
+
315
+ def session_reset_from_flow(flow, flow_name, session_key)
316
+ @session[flow_name.to_sym][session_key] = flow.session
317
+ end
318
+
319
+ def is_flow?(flow)
320
+ flow.is_a? Colloquy::FlowParser
321
+ end
322
+
323
+ def session_switch_flow(session_key, from, to, node_to = :index)
324
+ from_flow = is_flow?(from) ? from : @flows[from]
325
+ to_flow = is_flow?(to) ? to : @flows[to]
326
+ node_to ||= :index
327
+
328
+ unless is_flow?(to_flow)
329
+ raise Colloquy::JumpInvalidException, "Cannot find flow #{to} to switch to"
330
+ end
331
+
332
+ current_state = @state[from_flow.flow_name][session_key]
333
+
334
+ new_state = {
335
+ :switched_from => current_state,
336
+ :flow_name => to_flow.flow_name,
337
+ :node => node_to,
338
+ :flow_state => :init
339
+ }
340
+
341
+ # switch!
342
+ @state[from_flow.flow_name][session_key] = new_state
343
+ end
344
+
345
+ #
346
+ def state_load(flow_name, session_key, metadata = {})
347
+ flow_name = flow_name.to_sym
348
+
349
+ # extract the state if it's present
350
+ flow_state = (@state[flow_name][session_key] || {})
351
+ flow_session = (@session[flow_name][session_key] || {})
352
+
353
+ # Now, load the flow_pool_key from the flow_state if set
354
+ flow_pool_key = (flow_state[:flow_name] || flow_name).to_sym
355
+
356
+ # Pop a FlowParser object from flow pool
357
+ flow = Colloquy::Renderer::FlowPool.pop_flow(flow_pool_key)
358
+ raise Colloquy::FlowPoolEmpty, 'Flow pool is empty' unless flow
359
+
360
+ # Merge in the metadata
361
+ flow_state[:metadata] = metadata
362
+
363
+ [flow_state, flow_session, flow]
364
+ end
365
+
366
+ # Load, instantiate and save that instance in an instance variable
367
+ # @param [String, Hash] flow_entry Flow entry specified in flows yaml
368
+ def load_flow(flow_entry)
369
+ flow_path, flow_name, flow_class = extract_flow_components_from_entry(flow_entry)
370
+
371
+ # we load the flow dynamically by the earlier set load_path
372
+ require "#{flow_path}_flow"
373
+
374
+ @flows[flow_name.to_sym] = flow_class.constantize.new(flow_name, logger: @logger)
375
+ end
376
+
377
+ # Extract path, name and class of flow using entry in flows yaml
378
+ # @param [String, Hash] flow_entry Flow entry specified in flows yaml
379
+ # @return [Array] Path, Name and Class of given flow
380
+ def extract_flow_components_from_entry(flow_entry)
381
+ if flow_entry.is_a? Hash
382
+ flow_path = flow_entry.to_a.flatten.first
383
+ flow_name = flow_path.split('/').last
384
+ flow_class = flow_entry.to_a.flatten.last
385
+ else
386
+ flow_path = flow_entry
387
+ flow_name = flow_entry.split('/').last
388
+ flow_class = "#{flow_name}_flow".classify
389
+ end
390
+
391
+ [flow_path, flow_name, flow_class]
392
+ end
393
+
394
+ def initialize_session
395
+ @session ||= {}
396
+
397
+ @flows.each do |flow_name, flow|
398
+ @session[flow_name.to_sym] ||= session_store
399
+ end
400
+ end
401
+
402
+ def initialize_state
403
+ @state ||= {}
404
+
405
+ @flows.each do |flow_name, flow|
406
+ @state[flow_name.to_sym] ||= state_store
407
+ end
408
+ end
409
+
410
+ def session_store
411
+ Colloquy::SessionStore.haystack(@options[:flows][:session_store] || :memory, :identifier => :sessions)
412
+ end
413
+
414
+ def state_store
415
+ Colloquy::SessionStore.haystack(@options[:flows][:session_store] || :memory, :identifier => :state)
416
+ end
417
+
418
+ # @param [String, String] msisdn, session_id
419
+ # @return [String] session_key
420
+ def make_session_key(msisdn, session_id)
421
+ "#{msisdn}-#{session_id}"
422
+ end
423
+ end