Appointment Reminders Calls with Sinatra - Ruby
This application demonstrates how easy it is to place a call, accepting both DTMF and text input, and using SignalWire's advanced TTS capabilities to speak dates and times in the correct way. If the user changes their appointment to one of the slots we offer, we will also send them a reminder SMS.
What do I need to run this application?
Find the full code on Github
You will also need some additional packages:
SignalWire Ruby SDK
Sinatra for quickly creating web applications in Ruby
Dotenv for managing our environment variables
As well as a SignalWire account which you can create here, and your SignalWire space credentials which can be found in the API tab of your SignalWire space. For more information on navigating your space check out this guide.
Running the Application
Configure your .env file
Start by copying the env.example
file to a file named .env
, and fill in the necessary information.
The application needs a SignalWire API token. You can sign up here, then put the Project ID and Token in the .env
file as SIGNALWIRE_PROJECT_KEY
and SIGNALWIRE_PROJECT_KEY
, together with your full SignalWire Space URL as SIGNALWIRE_SPACE
. You will also need to set the APP_DOMAIN
to your server URL or SSH tunnel you'll use to access the code publicly.
Configure your json file
Then, copy the config.json.example
file to config.json
and change at least the phone number in the reminder block. You can also edit other values if you would like to test with multiple customers to 'remind' or different names.
Run the application natively
If you are running the application with Ruby on your computer, run bundle install
followed by bundle exec ruby app.rb
after configuring the .env
and config.json
files.
Build and Run on Docker
To use the bundled Docker configuration, set up your .env
and config.json
, build the container using docker build . -t rubyreminders
then run the application with docker run -it --rm -p 4567:4567 --name mfaruby --env-file .env rubyreminders
.
After starting the process with either of the two methods, head to http://localhost:4567
and click on "Confirm".
Step by Step Code Walkthrough
In the Github Repo you will find several files, but we are primarily interested in the app.rb
, config.json.example
, and env.example
files. Additionally there are the index.erb
and layout.erb
in the views folder which will be used by Sinatra.
The config.json file
The JSON file below contains two arrays of objects, reminders
and available
. The reminders
array contains information about the appointment and the to
number of the customer to call and remind. The available
array contains other possible date/time pairs that could be used for customers to reschedule their appointments. Although this JSON file is quite simple, it contains most of the data needed for our app.rb
file.
{
"reminders": [{
"name": "Mr. Smith",
"to": "+1214xxxxxxx",
"date": "05/10/2021",
"time": "10:00AM"
}],
"available" : [{
"date": "05/10/2021",
"time": "11:15AM"
},
{
"date": "06/10/2021",
"time": "2:30PM"
}]
}
app.rb
Create Call and Message Functions
To begin with, we must create two functions using the Create Call API to place a call to our customer and the Create Message API to send a message with the reminder in it.
For our place_call()
function, we can get the from
number and the url
from the ENV file, and we will append /reminder
to the url
so that we can direct it to the proper route that we will define further down. The to
number will be passed through as a parameter and we will get it from the config.json file.
def place_call(to_number)
client = Signalwire::REST::Client.new ENV['SIGNALWIRE_PROJECT_KEY'], ENV['SIGNALWIRE_TOKEN'], signalwire_space_url: ENV['SIGNALWIRE_SPACE']
call = client.calls.create(
url: ENV['APP_DOMAIN'] + '/reminder',
to: to_number,
from: ENV['FROM_NUMBER']
)
end
For our send_reminder()
function, we will get the from
number from the ENV file. The to
number will be passed through as a parameter along with the body
of the message.
def send_reminder(text, to_number)
client = Signalwire::REST::Client.new ENV['SIGNALWIRE_PROJECT_KEY'], ENV['SIGNALWIRE_TOKEN'], signalwire_space_url: ENV['SIGNALWIRE_SPACE']
msg = client.messages.create(
to: to_number,
from: ENV['FROM_NUMBER'],
body: text
)
end
Date-Time function
The last function we need to set up before moving forward is needed to handle reading dates/times that are in a computer-friendly format in a more human-friendly way. In this function, we will take the <Say>
object, date, and time as parameters. We will use the <Say>
object to compose a sentence where the date is read out loud instead of in a timestamp format.
def say_date_and_time(say_object, date, time)
say_object.say_as(date, interpretAs: 'date', format: 'mdy')
say_object.add_text(' at ')
say_object.say_as(time, interpretAs: 'time', format: 'hms12')
end
Sinatra Routes
Great! Now that we have defined all of our necessary functions, we can move on to the three Sinatra routes we will be using.
Default
Our first route is the default, /
, and it will store all of the reminders in the config.json
file into a variable. Using the index.erb
file in the GitHub repo, we will populate the data into a simple front-end display.
get '/' do
@reminders = config['reminders']
erb :index
end
/Call
When you click Confirm on the page above, the code in index.erb redirects to our next route, /call
.
In this route, we will grab the first object in the reminder array and use the to
number to pass through as a parameter in our place_call()
function.
get '/call' do
reminder = config['reminders'].first
place_call(reminder['to'])
redirect '/'
end
/Reminder
Next, we have our /reminder
route. As with any route involving gathering input from the customer, the first thing we need to do is check to see if any digits were pressed or speech results were gathered. Based on the input of the caller, we will either confirm the appointment and hang up or redirect to our route /choose
which will take care of changing the appointment.
If no speech result/digits pressed are passed through as parameters, we will assume this is the first time that we are speaking to the customer and play a greeting first. We will then use our say_date_and_time()
function to read out the appointment time for the customer. We will ask the callee to press 1 to confirm or 2 to reschedule and wait for a speech result or digit to be pressed.
post "/reminder" do
puts params
# set reminder to first element in reminders array from config file
reminder = config["reminders"].first
# instantiate voice response
response = Signalwire::Sdk::VoiceResponse.new
# check for digits or speech result passed through as http request parameters
# if speech/digit is 1, confirm appointment and hang up.
# if speech/digit is 2, redirect to choose route
# otherwise retry to read out appt and gather confirmation again
if params["Digits"] || params["SpeechResult"]
if params["Digits"] == "1" || params["SpeechResult"].match?(/yes/)
response.say(message: "Thank you for confirming your appointment. Goodbye!")
response.hangup
elsif params["Digits"] == "2" || params["SpeechResult"].match?(/no/)
response.redirect("/choose")
else
# we didn't understand
response.say(message: "Sorry, I could not understand you.")
response.redirect("/reminder?retry=1")
end
else
# read out hello message to the appointment holder, unless it is a retry in which case we'll repeat the main message only
response.say(message: "Hello, #{reminder["name"]}") unless params["retry"]
# read out appointment time by calling our say date and time functiona bove with the date and time from the reminder array
response.say(message: "We are calling you from the dental clinic to remind you about your appointment on ") do |say|
say_date_and_time(say, reminder["date"], reminder["time"])
end
# gather input, 1 to confirm, 2 to deny
response.gather(input: "speech dtmf", timeout: 5, num_digits: 1) do |gather|
message = "Do you confirm that date and time?"
message += "Press 1 or say yes to confirm, or press 2 or say no to choose another option." if params["retry"] == "1"
gather.say(message: message)
end
end
# convert response to string as response must return proper XML
response.to_s
end
/Choose
In our final route, we will handle changing an appointment if a customer would like to reschedule. If we have detected digits or speech, we will set picked
to the correct date based on what the customer input via speech or dtmf. Once the caller has chosen their new date, we will play a short message to read out the new date and then send a reminder text for good measure.
If no speech or digits are detected, we will assume it's the first time that we've accessed this route and play the caller a list of all of their possible appointment date/time options. We will then use <Gather>
to get their input and handle it accordingly.
post "/choose" do
# set reminder to first element in reminders array from config file
reminder = config["reminders"].first
# instantiate voice response
response = Signalwire::Sdk::VoiceResponse.new
# set up an array with first word string and second word string as parameters
words = %w{first second}
# handles sending reminder text for new appointment if appointment is changed
if params["Digits"] || params["SpeechResult"]
picked = nil
# based on parameters passed, assign one of the available dates to the variable picked
if params["Digits"] == "1" || params["SpeechResult"].match?(/first/)
picked = config["available"][0]
elsif params["Digits"] == "2" || params["SpeechResult"].match?(/second/)
picked = config["available"][1]
end
# if picked was assigned to something, play thank you message and call our say date and time function again to make the format more understandable for the caller
if picked
response.say(message: "Thank you! Your new appointment is on ") do |say|
say_date_and_time(say, picked["date"], picked["time"])
end
# call our send reminder function to send a message with the date and time passed through in the body parameter
send_reminder("Your appointment at the Dental Clinic is on #{picked["date"]} at #{picked["time"]}.", reminder["to"])
else
# if we didn't understand
response.say(message: "Sorry, let's try again.")
response.redirect("/choose")
end
# if no input has been detected, assume caller has not heard prompt and offer to make an appointment
else
response.say(message: "Let's pick another appointment for you. ")
# set up to gather input via dtmf or speech
response.gather(input: "speech dtmf", timeout: 5, num_digits: 1) do |gather|
# grab available dates from config.jason file and loop through them offering possible dates/times
gather.say do |say|
config["available"].each_with_index do |slot, i|
say.add_text("press #{i + 1} or say #{words[i]} for")
say_date_and_time(say, slot["date"], slot["time"])
end
end
end
end
# convert response to string as response must return proper XML
response.to_s
end
Wrap Up
This guide shows how easy it can be to create a fully functioning call reminder system in Ruby with the SignalWire Ruby SDK and Sinatra. By the end of this guide you will hopefully understand how SignalWire's API can be used to send outbound calls and sms, as well as use text-to-speech to prompt the user for dtmf and speech input.
Resources
Github
SignalWire Ruby SDK
Sinatra for quickly creating web applications in Ruby
Dotenv for managing our environment variables
Ngrok
Sign Up Here
If you would like to test this example out, you can create a SignalWire account and space here.
Please feel free to reach out to us on our Community Slack or create a Support ticket if you need guidance!