twilio-rails 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 (90) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +0 -0
  3. data/LICENSE +21 -0
  4. data/README.md +413 -0
  5. data/Rakefile +8 -0
  6. data/app/assets/config/twilio_rails_manifest.js +1 -0
  7. data/app/assets/stylesheets/twilio/rails/application.css +15 -0
  8. data/app/controllers/twilio/rails/application_controller.rb +6 -0
  9. data/app/controllers/twilio/rails/phone_controller.rb +112 -0
  10. data/app/controllers/twilio/rails/sms_controller.rb +64 -0
  11. data/app/helpers/twilio/rails/application_helper.rb +6 -0
  12. data/app/jobs/twilio/rails/application_job.rb +6 -0
  13. data/app/jobs/twilio/rails/phone/attach_recording_job.rb +15 -0
  14. data/app/jobs/twilio/rails/phone/finished_call_job.rb +15 -0
  15. data/app/jobs/twilio/rails/phone/unanswered_call_job.rb +15 -0
  16. data/app/mailers/twilio/rails/application_mailer.rb +8 -0
  17. data/app/models/twilio/rails/application_record.rb +7 -0
  18. data/app/operations/twilio/rails/application_operation.rb +21 -0
  19. data/app/operations/twilio/rails/find_or_create_phone_caller_operation.rb +29 -0
  20. data/app/operations/twilio/rails/phone/attach_recording_operation.rb +31 -0
  21. data/app/operations/twilio/rails/phone/base_operation.rb +21 -0
  22. data/app/operations/twilio/rails/phone/create_operation.rb +49 -0
  23. data/app/operations/twilio/rails/phone/find_operation.rb +14 -0
  24. data/app/operations/twilio/rails/phone/finished_call_operation.rb +17 -0
  25. data/app/operations/twilio/rails/phone/receive_recording_operation.rb +35 -0
  26. data/app/operations/twilio/rails/phone/start_call_operation.rb +53 -0
  27. data/app/operations/twilio/rails/phone/twiml/after_operation.rb +37 -0
  28. data/app/operations/twilio/rails/phone/twiml/base_operation.rb +50 -0
  29. data/app/operations/twilio/rails/phone/twiml/error_operation.rb +22 -0
  30. data/app/operations/twilio/rails/phone/twiml/greeting_operation.rb +22 -0
  31. data/app/operations/twilio/rails/phone/twiml/prompt_operation.rb +109 -0
  32. data/app/operations/twilio/rails/phone/twiml/prompt_response_operation.rb +29 -0
  33. data/app/operations/twilio/rails/phone/twiml/request_validation_failure_operation.rb +16 -0
  34. data/app/operations/twilio/rails/phone/twiml/timeout_operation.rb +48 -0
  35. data/app/operations/twilio/rails/phone/unanswered_call_operation.rb +22 -0
  36. data/app/operations/twilio/rails/phone/update_operation.rb +26 -0
  37. data/app/operations/twilio/rails/phone/update_response_operation.rb +38 -0
  38. data/app/operations/twilio/rails/sms/base_operation.rb +17 -0
  39. data/app/operations/twilio/rails/sms/create_operation.rb +23 -0
  40. data/app/operations/twilio/rails/sms/find_message_operation.rb +15 -0
  41. data/app/operations/twilio/rails/sms/find_operation.rb +15 -0
  42. data/app/operations/twilio/rails/sms/send_operation.rb +102 -0
  43. data/app/operations/twilio/rails/sms/twiml/base_operation.rb +11 -0
  44. data/app/operations/twilio/rails/sms/twiml/error_operation.rb +15 -0
  45. data/app/operations/twilio/rails/sms/twiml/message_operation.rb +49 -0
  46. data/app/operations/twilio/rails/sms/update_message_operation.rb +27 -0
  47. data/app/views/layouts/twilio/rails/application.html.erb +15 -0
  48. data/config/routes.rb +16 -0
  49. data/lib/generators/twilio/rails/install/USAGE +15 -0
  50. data/lib/generators/twilio/rails/install/install_generator.rb +34 -0
  51. data/lib/generators/twilio/rails/install/templates/initializer.rb +83 -0
  52. data/lib/generators/twilio/rails/install/templates/message.rb +4 -0
  53. data/lib/generators/twilio/rails/install/templates/migration.rb +89 -0
  54. data/lib/generators/twilio/rails/install/templates/phone_call.rb +4 -0
  55. data/lib/generators/twilio/rails/install/templates/phone_caller.rb +4 -0
  56. data/lib/generators/twilio/rails/install/templates/recording.rb +4 -0
  57. data/lib/generators/twilio/rails/install/templates/response.rb +4 -0
  58. data/lib/generators/twilio/rails/install/templates/sms_conversation.rb +4 -0
  59. data/lib/generators/twilio/rails/phone_tree/USAGE +8 -0
  60. data/lib/generators/twilio/rails/phone_tree/phone_tree_generator.rb +12 -0
  61. data/lib/generators/twilio/rails/phone_tree/templates/tree.rb.erb +13 -0
  62. data/lib/generators/twilio/rails/sms_responder/USAGE +8 -0
  63. data/lib/generators/twilio/rails/sms_responder/sms_responder_generator.rb +12 -0
  64. data/lib/generators/twilio/rails/sms_responder/templates/responder.rb.erb +10 -0
  65. data/lib/tasks/rails_tasks.rake +45 -0
  66. data/lib/twilio/rails/client.rb +75 -0
  67. data/lib/twilio/rails/concerns/has_direction.rb +25 -0
  68. data/lib/twilio/rails/concerns/has_phone_number.rb +27 -0
  69. data/lib/twilio/rails/concerns/has_time_scopes.rb +19 -0
  70. data/lib/twilio/rails/configuration.rb +380 -0
  71. data/lib/twilio/rails/engine.rb +11 -0
  72. data/lib/twilio/rails/formatter.rb +93 -0
  73. data/lib/twilio/rails/models/message.rb +21 -0
  74. data/lib/twilio/rails/models/phone_call.rb +132 -0
  75. data/lib/twilio/rails/models/phone_caller.rb +100 -0
  76. data/lib/twilio/rails/models/recording.rb +27 -0
  77. data/lib/twilio/rails/models/response.rb +153 -0
  78. data/lib/twilio/rails/models/sms_conversation.rb +29 -0
  79. data/lib/twilio/rails/phone/base_tree.rb +229 -0
  80. data/lib/twilio/rails/phone/tree.rb +229 -0
  81. data/lib/twilio/rails/phone/tree_macros.rb +147 -0
  82. data/lib/twilio/rails/phone.rb +12 -0
  83. data/lib/twilio/rails/phone_number.rb +29 -0
  84. data/lib/twilio/rails/railtie.rb +17 -0
  85. data/lib/twilio/rails/sms/delegated_responder.rb +97 -0
  86. data/lib/twilio/rails/sms/responder.rb +33 -0
  87. data/lib/twilio/rails/sms.rb +12 -0
  88. data/lib/twilio/rails/version.rb +5 -0
  89. data/lib/twilio/rails.rb +89 -0
  90. metadata +289 -0
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+ module Twilio
3
+ module Rails
4
+ module Phone
5
+ # Implementation class for a phone tree. See {Twilio::Rails::Phone::BaseTree} for detailed documentation.
6
+ class Tree
7
+ attr_reader :name, :prompts, :config
8
+ attr_accessor :greeting, :unanswered_call, :finished_call
9
+
10
+ def initialize(tree_name)
11
+ @name = tree_name.to_s
12
+ raise Twilio::Rails::Phone::InvalidTreeError, "tree name cannot be blank" unless name.present?
13
+
14
+ @prompts = {}.with_indifferent_access
15
+ @config = {}.with_indifferent_access
16
+
17
+ # defaults
18
+ @config[:voice] = "male"
19
+ @config[:final_timeout_message] = "Goodbye."
20
+ @config[:final_timeout_attempts] = 3
21
+ end
22
+
23
+ # The fully qualified URL for the tree used by Twilio to make outbound calls.
24
+ #
25
+ # @return [String] The outbound URL for the phone tree.
26
+ def outbound_url
27
+ "#{ ::Twilio::Rails.config.host }#{ ::Twilio::Rails::Engine.routes.url_helpers.phone_outbound_path(tree_name: name, format: :xml) }"
28
+ end
29
+
30
+ # The fully qualified URL for the tree used by Twilio to be configured in the dashboard.
31
+ #
32
+ # @return [String] The inbound URL for the phone tree.
33
+ def inbound_url
34
+ "#{ ::Twilio::Rails.config.host }#{ ::Twilio::Rails::Engine.routes.url_helpers.phone_inbound_path(tree_name: name, format: :xml) }"
35
+ end
36
+
37
+ class Prompt
38
+ attr_reader :name, :messages, :gather, :after
39
+
40
+ def initialize(name:, message:, gather:, after:)
41
+ @name = name&.to_sym
42
+ raise Twilio::Rails::Phone::InvalidTreeError, "prompt name cannot be blank" if @name.blank?
43
+
44
+ @messages = if message.is_a?(Proc)
45
+ message
46
+ else
47
+ Twilio::Rails::Phone::Tree::MessageSet.new(message)
48
+ end
49
+
50
+ @gather = Twilio::Rails::Phone::Tree::Gather.new(gather) if gather.present?
51
+ @after = Twilio::Rails::Phone::Tree::After.new(after)
52
+ end
53
+ end
54
+
55
+ class After
56
+ attr_reader :messages, :prompt, :proc
57
+
58
+ def initialize(args)
59
+ case args
60
+ when Symbol, String
61
+ @prompt = args.to_sym
62
+ when Proc
63
+ @proc = args
64
+ when Hash
65
+ args = args.with_indifferent_access
66
+ @prompt = args[:prompt]&.to_sym
67
+ @hangup = !!args[:hangup]
68
+
69
+ @messages = if args[:message].is_a?(Proc)
70
+ args[:message]
71
+ else
72
+ Twilio::Rails::Phone::Tree::MessageSet.new(args[:message])
73
+ end
74
+
75
+ raise Twilio::Rails::Phone::InvalidTreeError, "cannot have both prompt: and hangup:" if @prompt && @hangup
76
+ raise Twilio::Rails::Phone::InvalidTreeError, "must have either prompt: or hangup:" unless @prompt || @hangup
77
+ else
78
+ raise Twilio::Rails::Phone::InvalidTreeError, "cannot parse :after from #{args.inspect}"
79
+ end
80
+ end
81
+
82
+ def hangup?
83
+ !!@hangup
84
+ end
85
+ end
86
+
87
+ class Gather
88
+ attr_reader :type, :args
89
+
90
+ def initialize(args)
91
+ case args
92
+ when Proc
93
+ @proc = args
94
+ when Hash
95
+ @args = args.with_indifferent_access
96
+ @type = @args.delete(:type)&.to_sym
97
+
98
+ raise Twilio::Rails::Phone::InvalidTreeError, "gather :type must be :digits, :voice, or :speech but was #{@type.inspect}" unless [:digits, :voice, :speech].include?(@type)
99
+
100
+ if digits?
101
+ @args[:timeout] ||= 5
102
+ @args[:number] ||= 1
103
+ elsif voice?
104
+ @args[:length] ||= 10
105
+ @args[:beep] = true unless @args.key?(:beep)
106
+ @args[:transcribe] = false unless @args.key?(:transcribe)
107
+ @args[:profanity_filter] = false unless @args.key?(:profanity_filter)
108
+ elsif speech?
109
+ @args[:language] ||= "en-US"
110
+ else
111
+ raise Twilio::Rails::Phone::InvalidTreeError, "gather :type must be :digits, :voice, or :speech but was #{@type.inspect}"
112
+ end
113
+ else
114
+ raise Twilio::Rails::Phone::InvalidTreeError, "cannot parse :gather from #{args.inspect}"
115
+ end
116
+ end
117
+
118
+ def digits?
119
+ type == :digits
120
+ end
121
+
122
+ def voice?
123
+ type == :voice
124
+ end
125
+
126
+ def speech?
127
+ type == :speech
128
+ end
129
+
130
+ def interrupt?
131
+ if @args.key?(:interrupt)
132
+ !!@args[:interrupt]
133
+ else
134
+ false
135
+ end
136
+ end
137
+ end
138
+
139
+ class Message
140
+ attr_reader :value, :voice, :block
141
+
142
+ def initialize(say: nil, play: nil, pause: nil, voice: nil, &block)
143
+ @say = say.presence
144
+ @play = play.presence
145
+ @pause = pause.presence.to_i
146
+ @pause = nil if @pause == 0
147
+ @voice = voice.presence
148
+ @block = block if block_given?
149
+
150
+ raise Twilio::Rails::Phone::InvalidTreeError, "must only have one of say: play: pause:" if (@say && @play) || (@say && @pause) || (@play && @pause)
151
+ raise Twilio::Rails::Phone::InvalidTreeError, "say: must be a string or proc" if @say && !(@say.is_a?(String) || @say.is_a?(Proc))
152
+ raise Twilio::Rails::Phone::InvalidTreeError, "play: must be a string or proc" if @play && !(@play.is_a?(String) || @play.is_a?(Proc))
153
+ raise Twilio::Rails::Phone::InvalidTreeError, "play: be a valid url but is #{ @play }" if @play && @play.is_a?(String) && !@play.match(/^https?:\/\/.+/)
154
+ raise Twilio::Rails::Phone::InvalidTreeError, "pause: must be over zero but is #{ @pause }" if @pause && @pause <= 0
155
+ raise Twilio::Rails::Phone::InvalidTreeError, "block is only valid for say:" if block_given? && (@play || @pause)
156
+ end
157
+
158
+ def say?
159
+ !!(@say || @block)
160
+ end
161
+
162
+ def play?
163
+ !!@play
164
+ end
165
+
166
+ def pause?
167
+ !!@pause
168
+ end
169
+
170
+ def value
171
+ @say || @play || @pause
172
+ end
173
+ end
174
+
175
+ class MessageSet
176
+ include Enumerable
177
+
178
+ def initialize(set)
179
+ @messages = []
180
+
181
+ # This whole chunk here feels like an incorrect level of abstraction. That it should be the caller's responsbiility
182
+ # to pass in the contents of `message:` and not a hash with `message:` as a key. But maybe it's ok to do it once
183
+ # here so the callsites can be cleaner passthroughs without doing the checks over and over.
184
+ if set.is_a?(Hash)
185
+ set = set.symbolize_keys
186
+ if set.key?(:message)
187
+ raise Twilio::Rails::Phone::InvalidTreeError, "MessageSet should never receive a hash with any key other than :message but received #{ set }" if set.keys != [:message]
188
+ set = set[:message]
189
+ end
190
+ end
191
+
192
+ set = [set] unless set.is_a?(Array)
193
+ set.each do |message|
194
+ next nil if message.blank?
195
+
196
+ if message.is_a?(Twilio::Rails::Phone::Tree::Message)
197
+ @messages << message
198
+ elsif message.is_a?(Proc)
199
+ @messages << message
200
+ elsif message.is_a?(String)
201
+ @messages << Twilio::Rails::Phone::Tree::Message.new(say: message)
202
+ elsif message.is_a?(Hash)
203
+ @messages << Twilio::Rails::Phone::Tree::Message.new(**message.symbolize_keys)
204
+ else
205
+ raise Twilio::Rails::Phone::InvalidTreeError, "message value #{ message } is not valid"
206
+ end
207
+ end
208
+ end
209
+
210
+ def each(&block)
211
+ @messages.each(&block)
212
+ end
213
+
214
+ def length
215
+ @messages.count
216
+ end
217
+
218
+ def first
219
+ @messages.first
220
+ end
221
+
222
+ def last
223
+ @messages.last
224
+ end
225
+ end
226
+ end
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+ module Twilio
3
+ module Rails
4
+ module Phone
5
+ # This module is available as `macros` in context of generating phone trees. It provides a set of shortcuts for
6
+ # common or verboase actions. It can be extended with custom macros using the config option {Twilio::Rails::Configuration#include_phone_macros}
7
+ module TreeMacros
8
+ extend self
9
+
10
+ # Gather one digit, allowing the current speech to be interrupted and stopped when a digit is pressed, with a
11
+ # configurable timeout that defaults to 6 seconds.
12
+ #
13
+ # @param timeout [Integer] the number of seconds to wait for a digit before timing out, defaults to 6 seconds.
14
+ # @return [Hash] formatted to pass to `gather:`.
15
+ def digit_gather_interruptable(timeout: 6)
16
+ timeout = timeout.to_i.presence || 6
17
+ timeout = 6 if timeout < 1
18
+
19
+ {
20
+ type: :digits,
21
+ timeout: timeout.to_i.presence || 6,
22
+ number: 1,
23
+ interrupt: true,
24
+ finish_on_key: "",
25
+ }
26
+ end
27
+
28
+ # Split a number into its digits and join them with commas, in order for it to be read out as a list of digits.
29
+ # @example
30
+ # digits(123)
31
+ # "1, 2, 3"
32
+ #
33
+ # @param num [Integer, String] the integer number to split into digits.
34
+ # @return [String] the digits joined with commas.
35
+ def digits(num)
36
+ return "" if num.blank?
37
+ num.to_s.split("").join(", ")
38
+ end
39
+
40
+ # Pause for a number of seconds, defaults to 1 second. Useful when putting space between segments of speech.
41
+ #
42
+ # @param seconds [Integer] the number of seconds to pause for, defaults to 1 second.
43
+ # @return [Hash] formatted to pass to `message:`.
44
+ def pause(seconds=nil)
45
+ {
46
+ pause: (seconds.presence || 1),
47
+ }
48
+ end
49
+
50
+ # Format a list of choices such that they are a numbered list for a phone tree menu, and can be passed directly
51
+ # into a `say:`. This pairs perfectly with {#numbered_choice_response_includes?} for creating menus. The array
52
+ # of choices must be larger than 1 and less than 10, otherwise a {Twilio::Rails::Phone::Error} will be raised.
53
+ #
54
+ # @example
55
+ # numbered_choices(["store hours", "accounting", "warehouse"])
56
+ # [
57
+ # "For store hours, press 1.",
58
+ # "For accounting, press 2.",
59
+ # "For store warehouse, press 3.",
60
+ # ]
61
+ #
62
+ # @param choices [Array<String>] the list of choices in numbered order.
63
+ # @param prefix [String] the prefix to use before each choice, defaults to "For".
64
+ # @return [Array<String>] the list of choices with numbers and prefixes formatted for `say:`.
65
+ def numbered_choices(choices, prefix: nil)
66
+ raise Twilio::Rails::Phone::Error, "`numbered_choices` macro got an empty array" if choices.empty?
67
+ raise Twilio::Rails::Phone::Error, "`numbered_choices` macro cannot be more than 9" if choices.length > 9
68
+ prefix ||= "For"
69
+ choices.each_with_index.map { |choice, index| "#{ prefix } #{ choice }, press #{ index + 1 }." }.join(" ")
70
+ end
71
+
72
+ # Validates if the response object includes a digit that is within the range of the choices array. This pairs
73
+ # directly with {#numbered_choices} for creating menus and validating the input. The array of choices must be
74
+ # larger than 1 and less than 10, otherwise a {Twilio::Rails::Phone::Error} will be raised.
75
+ #
76
+ # @param choices [Array<String>] the list of choices in numbered order.
77
+ # @param response [Twilio::Rails::Phone::Models::Response] the response object to validate.
78
+ # @return [true, false] whether the response includes a digit that is within the range of the choices. Returns
79
+ # false also if there are no digits or the digit is out of range.
80
+ def numbered_choice_response_includes?(choices, response:)
81
+ raise Twilio::Rails::Phone::Error, "`numbered_choice_response_includes?` macro got an empty array" if choices.empty?
82
+ raise Twilio::Rails::Phone::Error, "`numbered_choice_response_includes?` macro cannot be more than 9" if choices.length > 9
83
+ !!(response.integer_digits && response.integer_digits > 0 && response.integer_digits <= choices.length)
84
+ end
85
+
86
+ # The list of configured answers that are considered "yes" from {Twilio::Rails::Configuration#yes_responses}.
87
+ #
88
+ # @return [Array<String>] the list of configured answers that are considered "yes".
89
+ def answers_yes
90
+ Twilio::Rails.config.yes_responses
91
+ end
92
+
93
+ # The list of configured answers that are considered "no" from {Twilio::Rails::Configuration#no_responses}.
94
+ #
95
+ # @return [Array<String>] the list of configured answers that are considered "no".
96
+ def answers_no
97
+ Twilio::Rails.config.no_responses
98
+ end
99
+
100
+ # Finds and validates the existence of a file in the `public` folder. Formats that link to include the
101
+ # configured hose from {Twilio::Rails::Configuration#host}, and returns a fully qualified URL to the file. This
102
+ # is useful for playing audio files in a `message:` block. If the file is not found
103
+ # {Twilio::Rails::Phone::Error} is raised.
104
+ #
105
+ # @param filename [String] the filename of the file to play located in the `public` folder.
106
+ # @return [String] the fully qualified URL to the file.
107
+ def public_file(filename)
108
+ filename = filename.gsub(/^\//, "")
109
+ local_path = ::Rails.public_path.join(filename)
110
+
111
+ if File.exist?(local_path)
112
+ "#{ ::Twilio::Rails.config.host }/#{ filename }"
113
+ else
114
+ raise Twilio::Rails::Phone::Error, "Cannot find public file '#{ filename }' at #{ local_path }"
115
+ end
116
+ end
117
+
118
+ # Wraps the result of {#public_file} in a hash that can be used directly as a `message:`.
119
+ #
120
+ # @param filename [String] the filename of the file to play located in the `public` folder.
121
+ # @return [Hash] formatted to pass to `message:`.
122
+ def play_public_file(filename)
123
+ { play: public_file(filename) }
124
+ end
125
+
126
+ # Expose a {Twilio::TwiML::Say} node to be used in a `message:` block. This can be used to form Speech Synthesis
127
+ # Markup Language (SSML) to be used with Amazon Polly. Note that SSML is only available with some Polly voices,
128
+ # and only some tags are supported. Twilio will return server errors if used incorrectly. See the Twilio
129
+ # documentation for more information:
130
+ # https://www.twilio.com/docs/voice/twiml/say/text-speech#ssml-with-amazon-polly
131
+ #
132
+ # @example
133
+ # prompt :with_ssml,
134
+ # message: macros.say { |say|
135
+ # say.emphasis(words: "with emphasis.", level: 'moderate')
136
+ # say.w(words: "A message.")
137
+ # say.prosody(words: 'Thank you for calling.', pitch: '-10%', rate: '110%')
138
+ # }
139
+ #
140
+ # @return [Twilio::Rails::Phone::Tree::Message] A message object passed the block that will yield a {Twilio::TwiML::Say} node
141
+ def say(&block)
142
+ Twilio::Rails::Phone::Tree::Message.new(&block)
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+ module Twilio
3
+ module Rails
4
+ module Phone
5
+ # Base error class for errors relating to Twilio phone interactions.
6
+ class Error < ::Twilio::Rails::Error ; end
7
+
8
+ # Error raised when attempting to build a phone tree.
9
+ class InvalidTreeError < Error ; end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+ module Twilio
3
+ module Rails
4
+ # A phone number object that includes the country and some optional metadata.
5
+ class PhoneNumber
6
+ attr_reader :number, :country, :label, :project
7
+
8
+ # @param number [String] the phone number string.
9
+ # @param country [String] the country code.
10
+ # @param label [String, nil] an optional label for the phone number, such as its source or purpose.
11
+ # @param project [String, nil] an optional project identifier for grouping phone numbers.
12
+ def initialize(number:, country:, label: nil, project: nil)
13
+ @number = Twilio::Rails::Formatter.coerce_to_valid_phone_number(number)
14
+ raise Twilio::Rails::Phone::Error, "Invalid phone number '#{ number }'" unless @number
15
+ @country = country&.upcase
16
+ @label = label
17
+ @project = project.presence&.to_s
18
+ end
19
+
20
+ # @return [String] a human readable string representation of the phone number and its metadata.
21
+ def to_s
22
+ s = "Phone number #{ number } (#{ country })"
23
+ s = "#{ s } #{ label }" if label.present?
24
+ s = "#{ s } for #{ project }" if project.present?
25
+ s
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+ module Twilio
3
+ module Rails
4
+ class Railtie < ::Rails::Railtie
5
+ config.before_initialize do
6
+ ActiveSupport::Inflector.inflections(:en) do |inflect|
7
+ inflect.acronym 'SMS'
8
+ end
9
+ end
10
+
11
+ config.after_initialize do |application|
12
+ # TODO: This should work but it does not. I think maybe it happens too late? The same line works if you add it directly to the `application.rb` of the app. It is needed for dev mode.
13
+ # application.config.hosts << Twilio::Rails.config.host_domain
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+ module Twilio
3
+ module Rails
4
+ module SMS
5
+ # Base class for SMS responders. To define a responder start by generating a sublcass.
6
+ #
7
+ # rails generate twilio:rails:sms_responder ThankYou
8
+ #
9
+ # This will create a new class in `app/sms_responders/thank_you_responder.rb` which will subclass this class. It
10
+ # must be registered with the framework in the initializer for it to be available. The generator does this.
11
+ #
12
+ # # config/initializers/twilio_rails.rb
13
+ # config.sms_responders.register { ThankYouResponder }
14
+ #
15
+ # Then the responder must implement the {#handle?} and {#reply} methods. If the {#handle?} method returns true
16
+ # then the {#reply} method will be called to generate the body of the response, and send that message back as an
17
+ # SMS. Only one responder will be called for a given message.
18
+ #
19
+ # @example
20
+ # class ThankYouResponder < ::Twilio::Rails::SMS::DelegatedResponder
21
+ # def handle?
22
+ # matches?(/thank you/)
23
+ # end
24
+ #
25
+ # def reply
26
+ # "Thank you too!"
27
+ # end
28
+ # end
29
+ class DelegatedResponder
30
+ attr_reader :message, :sms_conversation
31
+
32
+ class << self
33
+ # Returns the name of the class, without the namespace or the `Responder` suffix.
34
+ #
35
+ # @return [String] the name of the responder.
36
+ def responder_name
37
+ self.name.demodulize.underscore.gsub(/_responder\Z/, "")
38
+ end
39
+ end
40
+
41
+ def initialize(message)
42
+ @message = message
43
+ @sms_conversation = message.sms_conversation
44
+ end
45
+
46
+ # Must be implemented by the subclass otherwise will raise a `NotImplementedError`. Returns true if this
47
+ # responder should handle the given message. If true then the {#reply} method will be called to generate the
48
+ # body of the response. It has access to the message and the conversation.
49
+ #
50
+ # @return [true, false] true if this responder should handle the given message.
51
+ def handle?
52
+ raise NotImplementedError
53
+ end
54
+
55
+ # Must be implemented by the subclass otherwise will raise a `NotImplementedError`. Returns the body of the
56
+ # message to be sent in response. Will only be called if {#handle?} returns true. It has access to the message
57
+ # and the conversation.
58
+ #
59
+ # @return [String, nil] the body of the response to be sent as SMS, or `nil` if no message should be sent.
60
+ def reply
61
+ raise NotImplementedError
62
+ end
63
+
64
+ protected
65
+
66
+ # @return [PhoneCaller, nil] the phone caller associated with the message, or `nil` if none is found.
67
+ def phone_caller
68
+ @phone_caller ||= PhoneCaller.find_by(phone_number: @sms_conversation.from_number)
69
+ end
70
+
71
+ # @return [String] the phone number associated with the message.
72
+ def inbound_phone_number
73
+ sms_conversation.number
74
+ end
75
+
76
+ # Checks if the received message body contains or matches the given matcher. The matcher can be a string,
77
+ # symbol, or number and it will match anywhere in the body ignoring case. The matcher can also be a regex and
78
+ # it will just call `Regexp#match?` on the body. Raises an error if the matcher cannot be handled.
79
+ #
80
+ # @param matcher [String, Symbol, Numeric, Regexp] the matcher to check against the message body.
81
+ # @return [true, false] true if the message body matches the given matcher.
82
+ def matches?(matcher)
83
+ body = message.body || ""
84
+
85
+ case matcher
86
+ when String, Numeric, Symbol
87
+ body.downcase.include?(matcher.to_s.downcase)
88
+ when Regexp
89
+ matcher.match?(body)
90
+ else
91
+ raise Twilio::Rails::SMS::InvalidResponderError, "unkown matcher #{matcher}"
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,33 @@
1
+
2
+ # frozen_string_literal: true
3
+ module Twilio
4
+ module Rails
5
+ module SMS
6
+ # The class responsible for pattern matching and delegating how to handle an incoming SMS. Called by
7
+ # {Twilio::Rails::SMS::Twiml::MessageOperation} to generate the body of the response. For a given message it
8
+ # iterates over all registered `sms_responders` and replies with the first one that handles, or raises if none
9
+ # are found to handle the message.
10
+ class Responder
11
+ attr_reader :message, :sms_conversation
12
+
13
+ def initialize(message)
14
+ @message = message
15
+ @sms_conversation = message.sms_conversation
16
+ end
17
+
18
+ # Iterates over all registered `sms_responders` and replies with the first one that handles, or raises if none
19
+ # are found to handle the message.
20
+ #
21
+ # @return [String] the body of the response.
22
+ def respond
23
+ Twilio::Rails.config.sms_responders.all.each do |name, responder_class|
24
+ responder = responder_class.new(message)
25
+ return responder.reply if responder.handle?
26
+ end
27
+
28
+ raise Twilio::Rails::SMS::InvalidResponderError, "No responder found for message_id=#{ message.id } : #{ message.body }"
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+ module Twilio
3
+ module Rails
4
+ module SMS
5
+ # Base error class for errors relating to Twilio phone interactions.
6
+ class Error < ::Twilio::Rails::Error ; end
7
+
8
+ # Error raised when a responder is unable to handle an SMS message.
9
+ class InvalidResponderError < Error ; end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,5 @@
1
+ module Twilio
2
+ module Rails
3
+ VERSION = "1.0.0"
4
+ end
5
+ end
@@ -0,0 +1,89 @@
1
+ require "active_operation"
2
+ require "twilio-ruby"
3
+ require "faraday"
4
+
5
+ require "twilio/rails/version"
6
+ require "twilio/rails/engine"
7
+
8
+ module Twilio
9
+ module Rails
10
+ # Base error class for all errors raised by the Twilio::Rails gem. Every error is a subclass of this one.
11
+ class Error < StandardError ; end
12
+ end
13
+ end
14
+
15
+ require "twilio/rails/railtie"
16
+ require "twilio/rails/configuration"
17
+ require "twilio/rails/formatter"
18
+ require "twilio/rails/phone_number"
19
+ require "twilio/rails/client"
20
+
21
+ require "twilio/rails/phone"
22
+ require "twilio/rails/phone/tree"
23
+ require "twilio/rails/phone/base_tree"
24
+ require "twilio/rails/phone/tree_macros"
25
+
26
+ require "twilio/rails/sms"
27
+ require "twilio/rails/sms/responder"
28
+ require "twilio/rails/sms/delegated_responder"
29
+
30
+ require "twilio/rails/concerns/has_phone_number"
31
+ require "twilio/rails/concerns/has_time_scopes"
32
+ require "twilio/rails/concerns/has_direction"
33
+ require "twilio/rails/models/phone_caller"
34
+ require "twilio/rails/models/recording"
35
+ require "twilio/rails/models/phone_call"
36
+ require "twilio/rails/models/response"
37
+ require "twilio/rails/models/sms_conversation"
38
+ require "twilio/rails/models/message"
39
+
40
+ module Twilio
41
+ module Rails
42
+ class << self
43
+
44
+ # Read and write accessible configuration object. In most cases this should only be read after the app has been
45
+ # initialized. See {Twilio::Rails::Configuration} for more information.
46
+ #
47
+ # @return [Twilio::Rails::Configuration] the config object for the engine.
48
+ def config
49
+ @config ||= ::Twilio::Rails::Configuration.new
50
+ end
51
+
52
+ # Called in the `config/initializers/twilio_rails.rb` file to configure the engine. This yields the {.config}
53
+ # object above and then calls {Twilio::Rails::Configuration#validate!} to ensure the configuration is valid.
54
+ #
55
+ # @yield [Twilio::Rails::Configuration] the configuration object.
56
+ # @return [nil]
57
+ def setup
58
+ config.setup!
59
+ yield(config)
60
+ config.validate!
61
+ nil
62
+ end
63
+
64
+ # Abstraction for the framework to notify of an important exception that has occurred. This safely calls the
65
+ # configured `config.exception_notifier` or does nothing if it is set to `nil`. This does not catch, handle, or
66
+ # prevent the exception from raising.
67
+ #
68
+ # @param exception [Exception] the exception that has occurred.
69
+ # @param message [String] a description of the exception, defaults to `exception.message` if blank.
70
+ # @param context [Hash] a hash of arbitrary additional context to include in the notification.
71
+ # @param exception_binding [Binding] the binding of where the exception is being notified.
72
+ # @return [true, false] if an exception has been successfully notified.
73
+ def notify_exception(exception, message: nil, context: {}, exception_binding: nil)
74
+ if config.exception_notifier
75
+ begin
76
+ message = message.presence || exception.message
77
+ config.exception_notifier.call(exception, message, context, exception_binding)
78
+ true
79
+ rescue => e
80
+ config.logger.tagged(self.class) { |l| l.error("ExceptionNotifier failed to notify of exception=#{ exception.inspect } message=#{ message.inspect } context=#{ context.inspect }") }
81
+ false
82
+ end
83
+ else
84
+ false
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end