stealth 0.9.1

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 (56) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +58 -0
  3. data/.gitignore +12 -0
  4. data/Gemfile +6 -0
  5. data/Gemfile.lock +81 -0
  6. data/LICENSE +20 -0
  7. data/README.md +1 -0
  8. data/VERSION +1 -0
  9. data/bin/stealth +5 -0
  10. data/lib/stealth/base.rb +87 -0
  11. data/lib/stealth/cli.rb +82 -0
  12. data/lib/stealth/cli_base.rb +25 -0
  13. data/lib/stealth/commands/command.rb +14 -0
  14. data/lib/stealth/commands/console.rb +75 -0
  15. data/lib/stealth/commands/server.rb +20 -0
  16. data/lib/stealth/configuration.rb +54 -0
  17. data/lib/stealth/controller.rb +190 -0
  18. data/lib/stealth/dispatcher.rb +48 -0
  19. data/lib/stealth/errors.rb +32 -0
  20. data/lib/stealth/flow/base.rb +256 -0
  21. data/lib/stealth/flow/errors.rb +25 -0
  22. data/lib/stealth/flow/event.rb +43 -0
  23. data/lib/stealth/flow/event_collection.rb +41 -0
  24. data/lib/stealth/flow/specification.rb +67 -0
  25. data/lib/stealth/flow/state.rb +48 -0
  26. data/lib/stealth/jobs.rb +10 -0
  27. data/lib/stealth/logger.rb +16 -0
  28. data/lib/stealth/reply.rb +19 -0
  29. data/lib/stealth/server.rb +38 -0
  30. data/lib/stealth/service_message.rb +17 -0
  31. data/lib/stealth/service_reply.rb +30 -0
  32. data/lib/stealth/services/base_client.rb +28 -0
  33. data/lib/stealth/services/base_message_handler.rb +28 -0
  34. data/lib/stealth/services/base_reply_handler.rb +65 -0
  35. data/lib/stealth/services/facebook/client.rb +35 -0
  36. data/lib/stealth/services/facebook/events/message_event.rb +59 -0
  37. data/lib/stealth/services/facebook/events/postback_event.rb +36 -0
  38. data/lib/stealth/services/facebook/message_handler.rb +84 -0
  39. data/lib/stealth/services/facebook/reply_handler.rb +471 -0
  40. data/lib/stealth/services/facebook/setup.rb +25 -0
  41. data/lib/stealth/services/jobs/handle_message_job.rb +22 -0
  42. data/lib/stealth/session.rb +74 -0
  43. data/lib/stealth/version.rb +12 -0
  44. data/lib/stealth.rb +1 -0
  45. data/spec/configuration_spec.rb +52 -0
  46. data/spec/flow/custom_transitions_spec.rb +99 -0
  47. data/spec/flow/flow_spec.rb +91 -0
  48. data/spec/flow/transition_callbacks_spec.rb +228 -0
  49. data/spec/replies/nested_reply_with_erb.yml +16 -0
  50. data/spec/sample_services_yml/services.yml +31 -0
  51. data/spec/sample_services_yml/services_with_erb.yml +31 -0
  52. data/spec/service_reply_spec.rb +34 -0
  53. data/spec/spec_helper.rb +13 -0
  54. data/spec/version_spec.rb +16 -0
  55. data/stealth.gemspec +30 -0
  56. metadata +247 -0
@@ -0,0 +1,471 @@
1
+ # coding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module Stealth
5
+ module Services
6
+ module Facebook
7
+
8
+ class ReplyHandler < Stealth::Services::BaseReplyHandler
9
+
10
+ attr_reader :recipient_id, :reply
11
+
12
+ def initialize(recipient_id: nil, reply: nil)
13
+ @recipient_id = recipient_id
14
+ @reply = reply
15
+ end
16
+
17
+ def text
18
+ check_if_arguments_are_valid!(
19
+ suggestions: reply['suggestions'],
20
+ buttons: reply['buttons']
21
+ )
22
+
23
+ template = unstructured_template
24
+ template['message']['text'] = reply['text']
25
+
26
+ if reply['suggestions'].present?
27
+ fb_suggestions = generate_suggestions(suggestions: reply['suggestions'])
28
+ template["message"]["quick_replies"] = fb_suggestions
29
+ end
30
+
31
+ # If buttons are present, we need to convert this to a button template
32
+ if reply['buttons'].present?
33
+ template['message'].delete('text')
34
+
35
+ fb_buttons = generate_buttons(buttons: reply['buttons'])
36
+ attachment = button_attachment_template(text: reply['text'], buttons: fb_buttons)
37
+ template['message']['attachment'] = attachment
38
+ end
39
+
40
+ template
41
+ end
42
+
43
+ def image
44
+ check_if_arguments_are_valid!(
45
+ suggestions: reply['suggestions'],
46
+ buttons: reply['buttons']
47
+ )
48
+
49
+ template = unstructured_template
50
+ attachment = attachment_template(
51
+ attachment_type: 'image',
52
+ attachment_url: reply['image_url']
53
+ )
54
+ template['message']['attachment'] = attachment
55
+
56
+ if reply['suggestions'].present?
57
+ fb_suggestions = generate_suggestions(suggestions: reply['suggestions'])
58
+ template["message"]["quick_replies"] = fb_suggestions
59
+ end
60
+
61
+ template
62
+ end
63
+
64
+ def audio
65
+ check_if_arguments_are_valid!(
66
+ suggestions: reply['suggestions'],
67
+ buttons: reply['buttons']
68
+ )
69
+
70
+ template = unstructured_template
71
+ attachment = attachment_template(
72
+ attachment_type: 'audio',
73
+ attachment_url: reply['audio_url']
74
+ )
75
+ template['message']['attachment'] = attachment
76
+
77
+ if reply['suggestions'].present?
78
+ fb_suggestions = generate_suggestions(suggestions: reply['suggestions'])
79
+ template["message"]["quick_replies"] = fb_suggestions
80
+ end
81
+
82
+ template
83
+ end
84
+
85
+ def video
86
+ check_if_arguments_are_valid!(
87
+ suggestions: reply['suggestions'],
88
+ buttons: reply['buttons']
89
+ )
90
+
91
+ template = unstructured_template
92
+ attachment = attachment_template(
93
+ attachment_type: 'video',
94
+ attachment_url: reply['video_url']
95
+ )
96
+ template['message']['attachment'] = attachment
97
+
98
+ if reply['suggestions'].present?
99
+ fb_suggestions = generate_suggestions(suggestions: reply['suggestions'])
100
+ template["message"]["quick_replies"] = fb_suggestions
101
+ end
102
+
103
+ template
104
+ end
105
+
106
+ def file
107
+ check_if_arguments_are_valid!(
108
+ suggestions: reply['suggestions'],
109
+ buttons: reply['buttons']
110
+ )
111
+
112
+ template = unstructured_template
113
+ attachment = attachment_template(
114
+ attachment_type: 'file',
115
+ attachment_url: reply['file_url']
116
+ )
117
+ template['message']['attachment'] = attachment
118
+
119
+ if reply['suggestions'].present?
120
+ fb_suggestions = generate_suggestions(suggestions: reply['suggestions'])
121
+ template["message"]["quick_replies"] = fb_suggestions
122
+ end
123
+
124
+ template
125
+ end
126
+
127
+ def cards
128
+ template = card_template(
129
+ sharable: reply["details"]["sharable"],
130
+ aspect_ratio: reply["details"]["aspect_ratio"]
131
+ )
132
+
133
+ fb_elements = generate_card_elements(elements: reply["details"]["elements"])
134
+ template["message"]["attachments"]["payload"]["elements"] = fb_elements
135
+
136
+ template
137
+ end
138
+
139
+ def list
140
+ template = list_template(
141
+ top_element_style: reply["details"]["top_element_style"]
142
+ )
143
+
144
+ fb_elements = generate_list_elements(elements: reply["details"]["elements"])
145
+ template["message"]["attachments"]["payload"]["elements"] = fb_elements
146
+
147
+ if reply["details"]["buttons"].present?
148
+ if reply["details"]["buttons"].size > 1
149
+ raise(ArgumentError, "Facebook lists support a single button attached to the list itsef.")
150
+ end
151
+
152
+ template["message"]["attachments"]["payload"]["buttons"] = generate_buttons(buttons: reply["details"]["buttons"])
153
+ end
154
+
155
+ template
156
+ end
157
+
158
+ def mark_seen
159
+ sender_action_template(action: 'mark_seen')
160
+ end
161
+
162
+ def enable_typing_indicator
163
+ sender_action_template(action: 'typing_on')
164
+ end
165
+
166
+ def disable_typing_indicator
167
+ sender_action_template(action: 'typing_off')
168
+ end
169
+
170
+ def delay
171
+ enable_typing_indicator
172
+ end
173
+
174
+ # generates property/value pairs required to set the profile
175
+ def messenger_profile
176
+ unless Stealth.config.facebook.setup.present?
177
+ raise Stealth::Errors::ConfigurationError, "Setup for Facebook is not specified in services.yml."
178
+ end
179
+
180
+ profile = {}
181
+ Stealth.config.facebook.setup.each do |profile_option, _|
182
+ profile[profile_option] = self.send(profile_option)
183
+ end
184
+
185
+ profile
186
+ end
187
+
188
+ private
189
+
190
+ def unstructured_template
191
+ {
192
+ "recipient" => {
193
+ "id" => recipient_id
194
+ },
195
+ "message" => { }
196
+ }
197
+ end
198
+
199
+ def card_template(sharable: nil, aspect_ratio: nil)
200
+ template = {
201
+ "recipient" => {
202
+ "id" => recipient_id
203
+ },
204
+ "message" => {
205
+ "type" => "template",
206
+ "payload" => {
207
+ "template_type" => "generic",
208
+ "elements" => []
209
+ }
210
+ }
211
+ }
212
+
213
+ if sharable.present?
214
+ template["message"]["payload"]["sharable"] = sharable
215
+ end
216
+
217
+ if aspect_ratio.present?
218
+ template["message"]["payload"]["image_aspect_ratio"] = aspect_ratio
219
+ end
220
+
221
+ template
222
+ end
223
+
224
+ def list_template(top_element_style: nil, buttons: [])
225
+ template = {
226
+ "recipient" => {
227
+ "id" => recipient_id
228
+ },
229
+ "message" => {
230
+ "type" => "template",
231
+ "payload" => {
232
+ "template_type" => "list",
233
+ "elements" => []
234
+ }
235
+ }
236
+ }
237
+
238
+ if top_element_style.present?
239
+ unless ['large', 'compact'].include?(top_element_style)
240
+ raise(ArgumentError, "Facebook list replies only support 'large' or 'compact' as the top_element_style.")
241
+ end
242
+
243
+ template["message"]["payload"]["top_element_style"] = top_element_style
244
+ end
245
+
246
+ if buttons.present?
247
+ unless buttons.size > 1
248
+ raise(ArgumentError, "Facebook lists only support a single button in the top element.")
249
+ end
250
+
251
+ template["message"]["payload"]["buttons"] = aspect_ratio
252
+ end
253
+
254
+ template
255
+ end
256
+
257
+ def element_template(element_type:, element:)
258
+ unless element["text"].present?
259
+ raise(ArgumentError, "Facebook card and list elements must have a 'text' attribute.")
260
+ end
261
+
262
+ template = {
263
+ "title" => element["title"]
264
+ }
265
+
266
+ if element["subtitle"].present?
267
+ template["subtitle"] = element["subtitle"]
268
+ end
269
+
270
+ if element["image_url"].present?
271
+ template["image_url"] = element["image_url"]
272
+ end
273
+
274
+ if element["default_action"].present?
275
+ default_action = generate_default_action(action_params: element["default_action"])
276
+ template["default_action"] = default_action
277
+ end
278
+
279
+ if element["buttons"].present?
280
+ if element_type == 'card' && element["buttons"].size > 3
281
+ raise(ArgumentError, "Facebook card elements only support 3 buttons.")
282
+ end
283
+
284
+ if element_type == 'list' && element["buttons"].size > 1
285
+ raise(ArgumentError, "Facebook list elements only support 1 button.")
286
+ end
287
+
288
+ fb_buttons = generate_buttons(buttons: element["buttons"])
289
+ template["buttons"] = fb_buttons
290
+ end
291
+
292
+ template
293
+ end
294
+
295
+ def attachment_template(attachment_type:, attachment_url:)
296
+ {
297
+ "type" => attachment_type,
298
+ "payload" => {
299
+ "url" => attachment_url
300
+ }
301
+ }
302
+ end
303
+
304
+ def button_attachment_template(text:, buttons:)
305
+ {
306
+ "type" => "template",
307
+ "payload" => {
308
+ "template_type" => "button",
309
+ "text" => text,
310
+ "buttons" => buttons
311
+ }
312
+ }
313
+ end
314
+
315
+ def sender_action_template(action:)
316
+ {
317
+ "recipient" => {
318
+ "id" => recipient_id
319
+ },
320
+ "sender_action" => action
321
+ }
322
+ end
323
+
324
+ def generate_card_elements(elements:)
325
+ if elements.size > 10
326
+ raise(ArgumentError, "Facebook cards can have at most 10 cards.")
327
+ end
328
+
329
+ fb_elements = elements.collect do |element|
330
+ element_template(element_type: 'card', element: element)
331
+ end
332
+
333
+ fb_elements
334
+ end
335
+
336
+ def generate_list_elements(elements:)
337
+ if elements.size < 2 || elements.size > 4
338
+ raise(ArgumentError, "Facebook lists must have 2-4 elements.")
339
+ end
340
+
341
+ fb_elements = elements.collect do |element|
342
+ element_template(element_type: 'list', element: element)
343
+ end
344
+
345
+ fb_elements
346
+ end
347
+
348
+ def generate_suggestions(suggestions:)
349
+ quick_replies = suggestions.collect do |suggestion|
350
+ # If the user selected a location-type button, no other info needed
351
+ if suggestion["type"] == 'location'
352
+ quick_reply = { "content_type" => "location" }
353
+
354
+ # Facebook only supports these two types for now
355
+ else
356
+ quick_reply = {
357
+ "content_type" => "text",
358
+ "title" => suggestion["text"]
359
+ }
360
+
361
+ if suggestion["payload"].present?
362
+ quick_reply["payload"] = suggestion["payload"]
363
+ else
364
+ quick_reply["payload"] = suggestion["text"]
365
+ end
366
+
367
+ if suggestion["image_url"].present?
368
+ quick_reply["image_url"] = suggestion["image_url"]
369
+ end
370
+ end
371
+
372
+ quick_reply
373
+ end
374
+
375
+ quick_replies
376
+ end
377
+
378
+ # Requires adding support for Buy, Log In, Log Out, and Share button types
379
+ def generate_buttons(buttons:)
380
+ fb_buttons = buttons.collect do |button|
381
+ case button['type']
382
+ when 'url'
383
+ button = {
384
+ "type" => "web_url",
385
+ "url" => button["url"],
386
+ "title" => button["text"]
387
+ }
388
+
389
+ if button["webview_height"].present?
390
+ button["webview_height_ratio"] = button["webview_height"]
391
+ end
392
+
393
+ button
394
+
395
+ when 'payload'
396
+ button = {
397
+ "type" => "postback",
398
+ "payload" => button["payload"],
399
+ "title" => button["text"]
400
+ }
401
+
402
+ when 'call'
403
+ button = {
404
+ "type" => "phone_number",
405
+ "payload" => button["phone_number"],
406
+ "title" => button["text"]
407
+ }
408
+
409
+ when 'nested'
410
+ button = {
411
+ "type" => "nested",
412
+ "title" => button["text"],
413
+ "call_to_actions" => generate_buttons(buttons: button["buttons"])
414
+ }
415
+
416
+ else
417
+ raise(Stealth::Errors::ServiceImpaired, "Sorry, we don't yet support #{button["type"]} buttons yet!")
418
+ end
419
+
420
+ button
421
+ end
422
+
423
+ fb_buttons
424
+ end
425
+
426
+ def generate_default_action(action_params:)
427
+ default_action = {
428
+ "type" => "web_url",
429
+ "url" => action_params["url"]
430
+ }
431
+
432
+ if action_params["webview_height"].present?
433
+ action_params["webview_height_ratio"] = action_params["webview_height"]
434
+ end
435
+
436
+ default_action
437
+ end
438
+
439
+ def check_if_arguments_are_valid!(suggestions:, buttons:)
440
+ if suggestions.present? && buttons.present?
441
+ raise(ArgumentError, "A reply cannot have buttons and suggestions!")
442
+ end
443
+ end
444
+
445
+ def greeting
446
+ Stealth.config.facebook.setup.greeting.map do |greeting|
447
+ {
448
+ "locale" => greeting["locale"],
449
+ "text" => greeting["text"]
450
+ }
451
+ end
452
+ end
453
+
454
+ def persistent_menu
455
+ Stealth.config.facebook.setup.persistent_menu.map do |persistent_menu|
456
+ {
457
+ "locale" => persistent_menu['locale'],
458
+ "composer_input_disabled" => (persistent_menu['composer_input_disabled'] || false),
459
+ "call_to_actions" => generate_buttons(buttons: persistent_menu['call_to_actions'])
460
+ }
461
+ end
462
+ end
463
+
464
+ def get_started
465
+ Stealth.config.facebook.setup.get_started
466
+ end
467
+ end
468
+
469
+ end
470
+ end
471
+ end
@@ -0,0 +1,25 @@
1
+ # coding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'stealth/services/facebook/client'
5
+
6
+ module Stealth
7
+ module Services
8
+ module Facebook
9
+
10
+ class Setup
11
+
12
+ class << self
13
+ def trigger
14
+ reply_handler = Stealth::Services::Facebook::ReplyHandler.new
15
+ reply = reply_handler.messenger_profile
16
+ client = Stealth::Services::Facebook::Client.new(reply: reply, endpoint: 'messenger_profile')
17
+ client.transmit
18
+ end
19
+ end
20
+
21
+ end
22
+
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,22 @@
1
+ # coding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module Stealth
5
+ module Services
6
+
7
+ class HandleMessageJob < Stealth::Jobs
8
+ sidekiq_options queue: :webhooks, retry: false
9
+
10
+ def perform(service, params, headers)
11
+ dispatcher = Stealth::Dispatcher.new(
12
+ service: service,
13
+ params: params,
14
+ headers: headers
15
+ )
16
+
17
+ dispatcher.process
18
+ end
19
+ end
20
+
21
+ end
22
+ end
@@ -0,0 +1,74 @@
1
+ # coding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module Stealth
5
+ class Session
6
+
7
+ SLUG_SEPARATOR = '->'
8
+
9
+ attr_reader :session, :flow, :state, :user_id
10
+
11
+ def initialize(user_id:)
12
+ @user_id = user_id
13
+
14
+ unless defined?($redis)
15
+ raise(Stealth::Errors::RedisNotConfigured, "Please make sure REDIS_URL is configured before using sessions.")
16
+ end
17
+
18
+ get
19
+ self
20
+ end
21
+
22
+ def self.flow_and_state_from_session_slug(slug:)
23
+ {
24
+ flow: slug&.split(SLUG_SEPARATOR)&.first,
25
+ state: slug&.split(SLUG_SEPARATOR)&.last
26
+ }
27
+ end
28
+
29
+ def flow
30
+ @flow = begin
31
+ flow_klass = [flow_string, 'flow'].join('_').classify.constantize
32
+ flow = flow_klass.new.init_state(state_string)
33
+ flow
34
+ end
35
+ end
36
+
37
+ def state
38
+ flow.current_state
39
+ end
40
+
41
+ def flow_string
42
+ session&.split(SLUG_SEPARATOR)&.first
43
+ end
44
+
45
+ def state_string
46
+ session&.split(SLUG_SEPARATOR)&.last
47
+ end
48
+
49
+ def get
50
+ @session ||= $redis.get(user_id)
51
+ end
52
+
53
+ def set(flow:, state:)
54
+ @session = canonical_session_slug(flow: flow, state: state)
55
+ flow
56
+ $redis.set(user_id, canonical_session_slug(flow: flow, state: state))
57
+ end
58
+
59
+ def present?
60
+ session.present?
61
+ end
62
+
63
+ def blank?
64
+ !present?
65
+ end
66
+
67
+ private
68
+
69
+ def canonical_session_slug(flow:, state:)
70
+ [flow, state].join(SLUG_SEPARATOR)
71
+ end
72
+
73
+ end
74
+ end
@@ -0,0 +1,12 @@
1
+ # coding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module Stealth
5
+ module Version
6
+ def self.version
7
+ File.read(File.join(File.dirname(__FILE__), '../..', 'VERSION')).strip
8
+ end
9
+ end
10
+
11
+ VERSION = Version.version
12
+ end
data/lib/stealth.rb ADDED
@@ -0,0 +1 @@
1
+ require 'stealth/base'
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
4
+
5
+ describe "Stealth::Configuration" do
6
+
7
+ describe "accessing via method calling" do
8
+ let(:services_yml) { File.read(File.join(File.dirname(__FILE__), 'sample_services_yml', 'services.yml')) }
9
+ let(:parsed_config) { YAML.load(ERB.new(services_yml).result)[Stealth.env] }
10
+ let(:config) { Stealth.load_services_config!(services_yml) }
11
+
12
+ it "should return the root node" do
13
+ expect(config.facebook).to eq parsed_config['facebook']
14
+ end
15
+
16
+ it "should access deeply nested nodes" do
17
+ expect(config.facebook.setup.greeting).to eq parsed_config['facebook']['setup']['greeting']
18
+ end
19
+
20
+ it "should handle values that are arrays correctly" do
21
+ expect(config.facebook.setup.persistent_menu).to be_a(Array)
22
+ end
23
+
24
+ it "should retain the configuration at the class level" do
25
+ expect(Stealth.config.facebook.setup.greeting).to eq parsed_config['facebook']['setup']['greeting']
26
+ end
27
+
28
+ it "should handle multiple keys at the root level" do
29
+ expect(config.twilio_sms.account_sid).to eq parsed_config['twilio_sms']['account_sid']
30
+ end
31
+ end
32
+
33
+ describe "config files with ERB" do
34
+ let(:services_yml) { File.read(File.join(File.dirname(__FILE__), 'sample_services_yml', 'services_with_erb.yml')) }
35
+ let(:config) { Stealth.load_services_config!(services_yml) }
36
+
37
+ it "should replace available embedded env vars" do
38
+ ENV['FACEBOOK_VERIFY_TOKEN'] = 'it works'
39
+ expect(config.facebook.verify_token).to eq 'it works'
40
+ end
41
+
42
+ it "should replace unavailable embedded env vars with nil" do
43
+ expect(config.facebook.challenge).to be_nil
44
+ end
45
+
46
+ it "should not reload the configuration file if one already exists" do
47
+ Stealth.load_services_config(services_yml)
48
+ expect(config.facebook.challenge).to be_nil
49
+ end
50
+ end
51
+
52
+ end