stealth 1.1.2 → 2.0.0.beta1

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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +18 -8
  3. data/CHANGELOG.md +100 -0
  4. data/Gemfile +1 -1
  5. data/Gemfile.lock +49 -43
  6. data/LICENSE +4 -17
  7. data/README.md +9 -17
  8. data/VERSION +1 -1
  9. data/lib/stealth/base.rb +62 -13
  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 +179 -41
  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 +4 -4
  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 -39
  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