twilio-rails 1.0.0

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