stealth 1.1.6 → 2.0.0.beta1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +15 -54
  3. data/CHANGELOG.md +72 -0
  4. data/Gemfile +1 -1
  5. data/Gemfile.lock +49 -44
  6. data/LICENSE +4 -17
  7. data/README.md +9 -17
  8. data/VERSION +1 -1
  9. data/lib/stealth/base.rb +62 -15
  10. data/lib/stealth/cli.rb +1 -2
  11. data/lib/stealth/commands/console.rb +1 -1
  12. data/lib/stealth/configuration.rb +0 -3
  13. data/lib/stealth/controller/callbacks.rb +1 -1
  14. data/lib/stealth/controller/catch_all.rb +27 -4
  15. data/lib/stealth/controller/controller.rb +168 -49
  16. data/lib/stealth/controller/dev_jumps.rb +41 -0
  17. data/lib/stealth/controller/dynamic_delay.rb +4 -6
  18. data/lib/stealth/controller/interrupt_detect.rb +100 -0
  19. data/lib/stealth/controller/messages.rb +283 -0
  20. data/lib/stealth/controller/nlp.rb +50 -0
  21. data/lib/stealth/controller/replies.rb +178 -40
  22. data/lib/stealth/controller/unrecognized_message.rb +62 -0
  23. data/lib/stealth/core_ext.rb +5 -0
  24. data/lib/stealth/{flow/core_ext.rb → core_ext/numeric.rb} +0 -1
  25. data/lib/stealth/core_ext/string.rb +18 -0
  26. data/lib/stealth/dispatcher.rb +21 -0
  27. data/lib/stealth/errors.rb +12 -0
  28. data/lib/stealth/flow/base.rb +1 -2
  29. data/lib/stealth/flow/specification.rb +3 -2
  30. data/lib/stealth/flow/state.rb +3 -3
  31. data/lib/stealth/generators/builder/Gemfile +4 -3
  32. data/lib/stealth/generators/builder/bot/controllers/bot_controller.rb +42 -0
  33. data/lib/stealth/generators/builder/bot/controllers/catch_alls_controller.rb +2 -0
  34. data/lib/stealth/generators/builder/bot/controllers/goodbyes_controller.rb +2 -0
  35. data/lib/stealth/generators/builder/bot/controllers/hellos_controller.rb +2 -0
  36. data/lib/stealth/generators/builder/bot/controllers/interrupts_controller.rb +9 -0
  37. data/lib/stealth/generators/builder/bot/controllers/unrecognized_messages_controller.rb +9 -0
  38. data/lib/stealth/generators/builder/config/flow_map.rb +8 -0
  39. data/lib/stealth/generators/builder/config/initializers/autoload.rb +8 -0
  40. data/lib/stealth/generators/builder/config/initializers/inflections.rb +16 -0
  41. data/lib/stealth/generators/builder/config/puma.rb +15 -0
  42. data/lib/stealth/helpers/redis.rb +40 -0
  43. data/lib/stealth/lock.rb +83 -0
  44. data/lib/stealth/logger.rb +27 -18
  45. data/lib/stealth/nlp/client.rb +22 -0
  46. data/lib/stealth/nlp/result.rb +57 -0
  47. data/lib/stealth/reloader.rb +90 -0
  48. data/lib/stealth/reply.rb +17 -0
  49. data/lib/stealth/scheduled_reply.rb +3 -3
  50. data/lib/stealth/server.rb +3 -3
  51. data/lib/stealth/service_message.rb +3 -2
  52. data/lib/stealth/service_reply.rb +5 -1
  53. data/lib/stealth/services/base_reply_handler.rb +2 -2
  54. data/lib/stealth/session.rb +106 -53
  55. data/spec/configuration_spec.rb +9 -2
  56. data/spec/controller/callbacks_spec.rb +23 -28
  57. data/spec/controller/catch_all_spec.rb +81 -29
  58. data/spec/controller/controller_spec.rb +444 -43
  59. data/spec/controller/dynamic_delay_spec.rb +16 -18
  60. data/spec/controller/helpers_spec.rb +1 -2
  61. data/spec/controller/interrupt_detect_spec.rb +171 -0
  62. data/spec/controller/messages_spec.rb +744 -0
  63. data/spec/controller/nlp_spec.rb +93 -0
  64. data/spec/controller/replies_spec.rb +446 -11
  65. data/spec/controller/unrecognized_message_spec.rb +168 -0
  66. data/spec/dispatcher_spec.rb +79 -0
  67. data/spec/flow/flow_spec.rb +1 -2
  68. data/spec/flow/state_spec.rb +14 -3
  69. data/spec/helpers/redis_spec.rb +77 -0
  70. data/spec/lock_spec.rb +100 -0
  71. data/spec/nlp/client_spec.rb +23 -0
  72. data/spec/nlp/result_spec.rb +57 -0
  73. data/spec/replies/messages/say_msgs_without_breaks.yml +4 -0
  74. data/spec/replies/messages/say_randomize_speech.yml +10 -0
  75. data/spec/replies/messages/say_randomize_text.yml +10 -0
  76. data/spec/replies/messages/sub1/sub2/say_nested.yml +10 -0
  77. data/spec/reply_spec.rb +61 -0
  78. data/spec/scheduled_reply_spec.rb +23 -0
  79. data/spec/service_reply_spec.rb +1 -2
  80. data/spec/session_spec.rb +251 -12
  81. data/spec/spec_helper.rb +21 -0
  82. data/spec/support/controllers/vaders_controller.rb +24 -0
  83. data/spec/support/nlp_clients/dialogflow.rb +9 -0
  84. data/spec/support/nlp_clients/luis.rb +9 -0
  85. data/spec/support/nlp_results/luis_result.rb +163 -0
  86. data/spec/version_spec.rb +1 -2
  87. data/stealth.gemspec +6 -6
  88. metadata +83 -38
  89. data/docs/00-introduction.md +0 -37
  90. data/docs/01-getting-started.md +0 -21
  91. data/docs/02-local-development.md +0 -40
  92. data/docs/03-basics.md +0 -171
  93. data/docs/04-sessions.md +0 -29
  94. data/docs/05-controllers.md +0 -179
  95. data/docs/06-models.md +0 -39
  96. data/docs/07-replies.md +0 -114
  97. data/docs/08-catchalls.md +0 -49
  98. data/docs/09-messaging-integrations.md +0 -80
  99. data/docs/10-nlp-integrations.md +0 -13
  100. data/docs/11-analytics.md +0 -13
  101. data/docs/12-commands.md +0 -62
  102. data/docs/13-deployment.md +0 -50
  103. data/lib/stealth/generators/builder/config/initializers/.keep +0 -0
@@ -0,0 +1,62 @@
1
+ # coding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module Stealth
5
+ class Controller
6
+ module UnrecognizedMessage
7
+
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+
12
+ def run_unrecognized_message(err:)
13
+ err_message = "The message \"#{current_message.message}\" was not recognized in the original context."
14
+
15
+ Stealth::Logger.l(
16
+ topic: 'unrecognized_message',
17
+ message: err_message
18
+ )
19
+
20
+ unless defined?(UnrecognizedMessagesController)
21
+ Stealth::Logger.l(
22
+ topic: 'unrecognized_message',
23
+ message: 'Running catch_all; UnrecognizedMessagesController not defined.'
24
+ )
25
+
26
+ run_catch_all(err: err)
27
+ return false
28
+ end
29
+
30
+ unrecognized_msg_controller = UnrecognizedMessagesController.new(
31
+ service_message: current_message
32
+ )
33
+
34
+ begin
35
+ # Run handle_unrecognized_message action
36
+ unrecognized_msg_controller.handle_unrecognized_message
37
+
38
+ if unrecognized_msg_controller.progressed?
39
+ Stealth::Logger.l(
40
+ topic: 'unrecognized_message',
41
+ message: 'A match was detected. Skipping catch-all.'
42
+ )
43
+ else
44
+ # Log, but we don't want to run the catch_all for a poorly
45
+ # coded UnrecognizedMessagesController
46
+ Stealth::Logger.l(
47
+ topic: 'unrecognized_message',
48
+ message: 'Did not send replies, update session, or step'
49
+ )
50
+ end
51
+ rescue StandardError => e
52
+ # Run the catch_all directly since we're already in an unrecognized
53
+ # message state
54
+ run_catch_all(err: e)
55
+ end
56
+ end
57
+
58
+ end
59
+
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ Dir.glob(File.expand_path('core_ext/*.rb', __dir__)).each do |path|
4
+ require path
5
+ end
@@ -1,4 +1,3 @@
1
- # coding: utf-8
2
1
  # frozen_string_literal: true
3
2
 
4
3
  class Numeric
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ class String
4
+
5
+ EXCLUDED_CHARS = %w[" ' . , ! ? ( ) - _ ` ‘ ’ “ ”].freeze
6
+ EXCLUDED_CHARS_ESC = EXCLUDED_CHARS.map { |c| "\\#{c}" }
7
+ EXCLUDED_CHARS_RE = /#{EXCLUDED_CHARS_ESC.join('|')}/
8
+
9
+ # Removes blank padding and double+single quotes
10
+ def normalize
11
+ self.upcase.strip
12
+ end
13
+
14
+ def without_punctuation
15
+ self.gsub(EXCLUDED_CHARS_RE, '')
16
+ end
17
+
18
+ end
@@ -30,6 +30,11 @@ module Stealth
30
30
 
31
31
  def process
32
32
  service_message = message_handler.process
33
+
34
+ if Stealth.config.transcript_logging
35
+ log_incoming_message(service_message)
36
+ end
37
+
33
38
  bot_controller = BotController.new(service_message: service_message)
34
39
  bot_controller.route
35
40
  end
@@ -44,5 +49,21 @@ module Stealth
44
49
  end
45
50
  end
46
51
 
52
+ def log_incoming_message(service_message)
53
+ message = if service_message.location.present?
54
+ "Received: <user shared location>"
55
+ elsif service_message.attachments.present?
56
+ "Received: <user sent attachment>"
57
+ elsif service_message.payload.present?
58
+ "Received Payload: #{service_message.payload}"
59
+ else
60
+ "Received Message: #{service_message.message}"
61
+ end
62
+
63
+ Stealth::Logger.l(
64
+ topic: 'user',
65
+ message: "User #{service_message.sender_id} -> #{message}"
66
+ )
67
+ end
47
68
  end
48
69
  end
@@ -34,11 +34,23 @@ module Stealth
34
34
  class ReplyNotFound < Errors
35
35
  end
36
36
 
37
+ class UnrecognizedMessage < Errors
38
+ end
39
+
37
40
  class FlowError < Errors
38
41
  end
39
42
 
40
43
  class FlowDefinitionError < Errors
41
44
  end
42
45
 
46
+ class InvalidSessionID < Errors
47
+ end
48
+
49
+ class UserOptOut < Errors
50
+ end
51
+
52
+ class ReservedHomophoneUsed < Errors
53
+ end
54
+
43
55
  end
44
56
  end
@@ -1,7 +1,6 @@
1
1
  # coding: utf-8
2
2
  # frozen_string_literal: true
3
3
 
4
- require 'stealth/flow/core_ext'
5
4
  require 'stealth/flow/specification'
6
5
  require 'stealth/flow/state'
7
6
 
@@ -55,7 +54,7 @@ module Stealth
55
54
  private
56
55
 
57
56
  def flow_and_state
58
- [current_flow, current_state].join("->")
57
+ [current_flow, current_state].join(Stealth::Session::SLUG_SEPARATOR)
59
58
  end
60
59
 
61
60
  def state_exists?(potential_flow:, potential_state:)
@@ -19,7 +19,7 @@ module Stealth
19
19
 
20
20
  private
21
21
 
22
- def state(name, fails_to: nil, redirects_to: nil)
22
+ def state(name, fails_to: nil, redirects_to: nil, **opts)
23
23
  fail_state = get_fail_or_redirect_state(fails_to)
24
24
  redirect_state = get_fail_or_redirect_state(redirects_to)
25
25
 
@@ -27,7 +27,8 @@ module Stealth
27
27
  name: name,
28
28
  spec: self,
29
29
  fails_to: fail_state,
30
- redirects_to: redirect_state
30
+ redirects_to: redirect_state,
31
+ opts: opts
31
32
  )
32
33
 
33
34
  @initial_state = new_state if @states.empty?
@@ -8,9 +8,9 @@ module Stealth
8
8
  include Comparable
9
9
 
10
10
  attr_accessor :name
11
- attr_reader :spec, :fails_to, :redirects_to
11
+ attr_reader :spec, :fails_to, :redirects_to, :opts
12
12
 
13
- def initialize(name:, spec:, fails_to: nil, redirects_to: nil)
13
+ def initialize(name:, spec:, fails_to: nil, redirects_to: nil, opts:)
14
14
  if fails_to.present? && !fails_to.is_a?(Stealth::Session)
15
15
  raise(ArgumentError, 'fails_to state should be a Stealth::Session')
16
16
  end
@@ -20,7 +20,7 @@ module Stealth
20
20
  end
21
21
 
22
22
  @name, @spec = name, spec
23
- @fails_to, @redirects_to = fails_to, redirects_to
23
+ @fails_to, @redirects_to, @opts = fails_to, redirects_to, opts
24
24
  end
25
25
 
26
26
  def <=>(other_state)
@@ -1,9 +1,9 @@
1
1
  source 'https://rubygems.org'
2
2
 
3
- gem 'stealth', '~> 1.0'
3
+ gem 'stealth', '~> 2.0'
4
4
 
5
- gem 'railties', '~> 5.2'
6
- gem 'activerecord', '~> 5.2'
5
+ gem 'railties', '~> 6.0'
6
+ gem 'activerecord', '~> 6.0'
7
7
  # Use sqlite3 as the database for Active Record
8
8
  gem 'sqlite3'
9
9
 
@@ -15,4 +15,5 @@ gem 'sqlite3'
15
15
 
16
16
  group :development do
17
17
  gem 'foreman'
18
+ gem 'listen', '~> 3.1'
18
19
  end
@@ -1,8 +1,24 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class BotController < Stealth::Controller
2
4
 
3
5
  helper :all
4
6
 
5
7
  def route
8
+ if current_message.payload.present?
9
+ handle_payloads
10
+ # Clear out the payload to prevent duplicate handling
11
+ current_message.payload = nil
12
+ return
13
+ end
14
+
15
+ # Allow devs to jump around flows and states by typing:
16
+ # /flow_name/state_name or
17
+ # /flow_name (jumps to first state) or
18
+ # //state_name (jumps to state in current flow)
19
+ # (only works for bots in development)
20
+ return if dev_jump_detected?
21
+
6
22
  if current_session.present?
7
23
  step_to session: current_session
8
24
  else
@@ -10,4 +26,30 @@ class BotController < Stealth::Controller
10
26
  end
11
27
  end
12
28
 
29
+ private
30
+
31
+ # Handle payloads globally since payload buttons remain in the chat
32
+ # and we cannot guess in which states they will be tapped.
33
+ def handle_payloads
34
+ case current_message.payload
35
+ when 'developer_restart', 'new_user'
36
+ step_to flow: 'hello', state: 'say_hello'
37
+ when 'goodbye'
38
+ step_to flow: 'goodbye'
39
+ end
40
+ end
41
+
42
+ # Automatically called when clients receive an opt-out error from
43
+ # the platform. You can write your own steps for handling.
44
+ def handle_opt_out
45
+ do_nothing
46
+ end
47
+
48
+ # Automatically called when clients receive an invalid session_id error from
49
+ # the platform. For example, attempting to text a landline.
50
+ # You can write your own steps for handling.
51
+ def handle_invalid_session_id
52
+ do_nothing
53
+ end
54
+
13
55
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class CatchAllsController < BotController
2
4
 
3
5
  def level1
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class GoodbyesController < BotController
2
4
 
3
5
  def say_goodbye
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class HellosController < BotController
2
4
 
3
5
  def say_hello
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class InterruptsController < BotController
4
+
5
+ def say_interrupted
6
+ do_nothing
7
+ end
8
+
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class UnrecognizedMessagesController < BotController
4
+
5
+ def handle_unrecognized_message
6
+ do_nothing
7
+ end
8
+
9
+ end
@@ -10,6 +10,14 @@ class FlowMap
10
10
  state :say_goodbye
11
11
  end
12
12
 
13
+ flow :interrupt do
14
+ state :say_interrupted
15
+ end
16
+
17
+ flow :unrecognized_message do
18
+ state :handle_unrecognized_message
19
+ end
20
+
13
21
  flow :catch_all do
14
22
  state :level1
15
23
  end
@@ -0,0 +1,8 @@
1
+ # Add additional directories below for hot-reloading during development.
2
+ # You'll want to include any custom directories you create for your code.
3
+ # To stop certain files or directories from autoloading, use autoload_ignore_paths
4
+
5
+ # Stealth.config.autoload_paths << File.join(Stealth.root, 'bot', 'services')
6
+ # Stealth.config.autoload_paths << File.join(Stealth.root, 'bot', 'jobs')
7
+
8
+ # Stealth.config.autoload_ignore_paths << File.join(Stealth.root, 'bot', 'overrides')
@@ -0,0 +1,16 @@
1
+ # Be sure to restart your server when you modify this file.
2
+
3
+ # Add new inflection rules using the following format. Inflections
4
+ # are locale specific, and you may define rules for as many different
5
+ # locales as you wish. All of these examples are active by default:
6
+ # ActiveSupport::Inflector.inflections(:en) do |inflect|
7
+ # inflect.plural /^(ox)$/i, '\1en'
8
+ # inflect.singular /^(ox)en/i, '\1'
9
+ # inflect.irregular 'person', 'people'
10
+ # inflect.uncountable %w( fish sheep )
11
+ # end
12
+
13
+ # These inflection rules are supported but not enabled by default:
14
+ # ActiveSupport::Inflector.inflections(:en) do |inflect|
15
+ # inflect.acronym 'RESTful'
16
+ # end
@@ -5,6 +5,21 @@ threads threads_count, threads_count
5
5
  #
6
6
  port ENV.fetch("PORT") { 3000 }
7
7
 
8
+ # Specifies the number of `workers` to boot in clustered mode.
9
+ # Workers are forked webserver processes. If using threads and workers together
10
+ # the concurrency of the application would be max `threads` * `workers`.
11
+ # Workers do not work on JRuby or Windows (both of which do not support
12
+ # processes).
13
+ #
14
+ # workers ENV.fetch("WEB_CONCURRENCY") { 2 }
15
+
16
+ # Use the `preload_app!` method when specifying a `workers` number.
17
+ # This directive tells Puma to first boot the application and load code
18
+ # before forking the application. This takes advantage of Copy On Write
19
+ # process behavior so workers use less memory.
20
+ #
21
+ # preload_app!
22
+
8
23
  # Specifies the `environment` that Puma will run in.
9
24
  #
10
25
  environment ENV.fetch("STEALTH_ENV") { "development" }
@@ -0,0 +1,40 @@
1
+ # coding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module Stealth
5
+ module Redis
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ private
10
+
11
+ def get_key(key, expiration: Stealth.config.session_ttl)
12
+ if expiration > 0
13
+ getex(key, expiration)
14
+ else
15
+ $redis.get(key)
16
+ end
17
+ end
18
+
19
+ def delete_key(key)
20
+ $redis.del(key)
21
+ end
22
+
23
+ def getex(key, expiration=Stealth.config.session_ttl)
24
+ $redis.multi do
25
+ $redis.expire(key, expiration)
26
+ $redis.get(key)
27
+ end.last
28
+ end
29
+
30
+ def persist_key(key:, value:, expiration: Stealth.config.session_ttl)
31
+ if expiration > 0
32
+ $redis.setex(key, expiration, value)
33
+ else
34
+ $redis.set(key, value)
35
+ end
36
+ end
37
+
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,83 @@
1
+ # coding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module Stealth
5
+ class Lock
6
+
7
+ include Stealth::Redis
8
+
9
+ attr_accessor :session_id, :session_slug, :position, :tid
10
+
11
+ def initialize(session_id:, session_slug: nil, position: nil)
12
+ @session_id = session_id
13
+ @session_slug = session_slug
14
+ @position = position
15
+ @tid = Stealth.tid
16
+ end
17
+
18
+ def self.find_lock(session_id:)
19
+ lock = Lock.new(session_id: session_id)
20
+ lock_slug = lock.slug # fetch lock from Redis
21
+
22
+ return if lock_slug.nil?
23
+
24
+ # parse the lock slug
25
+ tid_and_session_slug, position = lock_slug.split(':')
26
+ tid, session_slug = tid_and_session_slug.split('#')
27
+
28
+ # set the values from the slug to the lock object
29
+ lock.session_slug = session_slug
30
+ lock.position = position&.to_i
31
+ lock.tid = tid
32
+ lock
33
+ end
34
+
35
+ def create
36
+ if session_slug.blank?
37
+ raise(
38
+ ArgumentError,
39
+ 'A session_slug must be specified before a lock can be created.'
40
+ )
41
+ end
42
+
43
+ # Expire locks after 30 seconds to prevent zombie locks from blocking
44
+ # other threads to interact with a session.
45
+ persist_key(
46
+ key: lock_key,
47
+ value: generate_lock,
48
+ expiration: Stealth.config.lock_autorelease
49
+ )
50
+ end
51
+
52
+ def release
53
+ delete_key(lock_key)
54
+ end
55
+
56
+ def slug
57
+ # We don't want to extend the expiration time that would result if
58
+ # we specified one here.
59
+ get_key(lock_key, expiration: 0)
60
+ end
61
+
62
+ # Returns a hash:
63
+ # { flow: 'flow_name', state: 'state_name' }
64
+ def flow_and_state
65
+ Session.flow_and_state_from_session_slug(slug: session_slug)
66
+ end
67
+
68
+ private
69
+
70
+ def lock_key
71
+ [@session_id, 'lock'].join('-')
72
+ end
73
+
74
+ def generate_lock
75
+ if @position.present?
76
+ @session_slug = [@session_slug, @position].join(':')
77
+ end
78
+
79
+ [@tid, @session_slug].join('#')
80
+ end
81
+
82
+ end
83
+ end