colloquy 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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