Appointment Reminders Calls with Sinatra - Ruby

Overview

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.

📘

You may need to use a SSH tunnel for testing this code if running on your local machine. – we recommend ngrok. You can learn more about how to use ngrok here. After starting the tunnel, you can use the URL you receive from ngrok in the .env APP_DOMAIN variable.

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 object, date, and time as parameters. We will use the 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 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!


What’s Next

Check out the full code on our SignalWire Github Repo!

Did this page help you?