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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +0 -0
- data/LICENSE +21 -0
- data/README.md +413 -0
- data/Rakefile +8 -0
- data/app/assets/config/twilio_rails_manifest.js +1 -0
- data/app/assets/stylesheets/twilio/rails/application.css +15 -0
- data/app/controllers/twilio/rails/application_controller.rb +6 -0
- data/app/controllers/twilio/rails/phone_controller.rb +112 -0
- data/app/controllers/twilio/rails/sms_controller.rb +64 -0
- data/app/helpers/twilio/rails/application_helper.rb +6 -0
- data/app/jobs/twilio/rails/application_job.rb +6 -0
- data/app/jobs/twilio/rails/phone/attach_recording_job.rb +15 -0
- data/app/jobs/twilio/rails/phone/finished_call_job.rb +15 -0
- data/app/jobs/twilio/rails/phone/unanswered_call_job.rb +15 -0
- data/app/mailers/twilio/rails/application_mailer.rb +8 -0
- data/app/models/twilio/rails/application_record.rb +7 -0
- data/app/operations/twilio/rails/application_operation.rb +21 -0
- data/app/operations/twilio/rails/find_or_create_phone_caller_operation.rb +29 -0
- data/app/operations/twilio/rails/phone/attach_recording_operation.rb +31 -0
- data/app/operations/twilio/rails/phone/base_operation.rb +21 -0
- data/app/operations/twilio/rails/phone/create_operation.rb +49 -0
- data/app/operations/twilio/rails/phone/find_operation.rb +14 -0
- data/app/operations/twilio/rails/phone/finished_call_operation.rb +17 -0
- data/app/operations/twilio/rails/phone/receive_recording_operation.rb +35 -0
- data/app/operations/twilio/rails/phone/start_call_operation.rb +53 -0
- data/app/operations/twilio/rails/phone/twiml/after_operation.rb +37 -0
- data/app/operations/twilio/rails/phone/twiml/base_operation.rb +50 -0
- data/app/operations/twilio/rails/phone/twiml/error_operation.rb +22 -0
- data/app/operations/twilio/rails/phone/twiml/greeting_operation.rb +22 -0
- data/app/operations/twilio/rails/phone/twiml/prompt_operation.rb +109 -0
- data/app/operations/twilio/rails/phone/twiml/prompt_response_operation.rb +29 -0
- data/app/operations/twilio/rails/phone/twiml/request_validation_failure_operation.rb +16 -0
- data/app/operations/twilio/rails/phone/twiml/timeout_operation.rb +48 -0
- data/app/operations/twilio/rails/phone/unanswered_call_operation.rb +22 -0
- data/app/operations/twilio/rails/phone/update_operation.rb +26 -0
- data/app/operations/twilio/rails/phone/update_response_operation.rb +38 -0
- data/app/operations/twilio/rails/sms/base_operation.rb +17 -0
- data/app/operations/twilio/rails/sms/create_operation.rb +23 -0
- data/app/operations/twilio/rails/sms/find_message_operation.rb +15 -0
- data/app/operations/twilio/rails/sms/find_operation.rb +15 -0
- data/app/operations/twilio/rails/sms/send_operation.rb +102 -0
- data/app/operations/twilio/rails/sms/twiml/base_operation.rb +11 -0
- data/app/operations/twilio/rails/sms/twiml/error_operation.rb +15 -0
- data/app/operations/twilio/rails/sms/twiml/message_operation.rb +49 -0
- data/app/operations/twilio/rails/sms/update_message_operation.rb +27 -0
- data/app/views/layouts/twilio/rails/application.html.erb +15 -0
- data/config/routes.rb +16 -0
- data/lib/generators/twilio/rails/install/USAGE +15 -0
- data/lib/generators/twilio/rails/install/install_generator.rb +34 -0
- data/lib/generators/twilio/rails/install/templates/initializer.rb +83 -0
- data/lib/generators/twilio/rails/install/templates/message.rb +4 -0
- data/lib/generators/twilio/rails/install/templates/migration.rb +89 -0
- data/lib/generators/twilio/rails/install/templates/phone_call.rb +4 -0
- data/lib/generators/twilio/rails/install/templates/phone_caller.rb +4 -0
- data/lib/generators/twilio/rails/install/templates/recording.rb +4 -0
- data/lib/generators/twilio/rails/install/templates/response.rb +4 -0
- data/lib/generators/twilio/rails/install/templates/sms_conversation.rb +4 -0
- data/lib/generators/twilio/rails/phone_tree/USAGE +8 -0
- data/lib/generators/twilio/rails/phone_tree/phone_tree_generator.rb +12 -0
- data/lib/generators/twilio/rails/phone_tree/templates/tree.rb.erb +13 -0
- data/lib/generators/twilio/rails/sms_responder/USAGE +8 -0
- data/lib/generators/twilio/rails/sms_responder/sms_responder_generator.rb +12 -0
- data/lib/generators/twilio/rails/sms_responder/templates/responder.rb.erb +10 -0
- data/lib/tasks/rails_tasks.rake +45 -0
- data/lib/twilio/rails/client.rb +75 -0
- data/lib/twilio/rails/concerns/has_direction.rb +25 -0
- data/lib/twilio/rails/concerns/has_phone_number.rb +27 -0
- data/lib/twilio/rails/concerns/has_time_scopes.rb +19 -0
- data/lib/twilio/rails/configuration.rb +380 -0
- data/lib/twilio/rails/engine.rb +11 -0
- data/lib/twilio/rails/formatter.rb +93 -0
- data/lib/twilio/rails/models/message.rb +21 -0
- data/lib/twilio/rails/models/phone_call.rb +132 -0
- data/lib/twilio/rails/models/phone_caller.rb +100 -0
- data/lib/twilio/rails/models/recording.rb +27 -0
- data/lib/twilio/rails/models/response.rb +153 -0
- data/lib/twilio/rails/models/sms_conversation.rb +29 -0
- data/lib/twilio/rails/phone/base_tree.rb +229 -0
- data/lib/twilio/rails/phone/tree.rb +229 -0
- data/lib/twilio/rails/phone/tree_macros.rb +147 -0
- data/lib/twilio/rails/phone.rb +12 -0
- data/lib/twilio/rails/phone_number.rb +29 -0
- data/lib/twilio/rails/railtie.rb +17 -0
- data/lib/twilio/rails/sms/delegated_responder.rb +97 -0
- data/lib/twilio/rails/sms/responder.rb +33 -0
- data/lib/twilio/rails/sms.rb +12 -0
- data/lib/twilio/rails/version.rb +5 -0
- data/lib/twilio/rails.rb +89 -0
- 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
|
data/lib/twilio/rails.rb
ADDED
@@ -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
|