stealth 0.9.1

Sign up to get free protection for your applications and to get access to all the features.
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