Twilio Flex – Advanced Schedules

The call center operates weekdays only and is closed during the holidays. Also, the call center closes early on the day before special holidays. The product team wants to play a message in the IVR during the close hours.

Setting up the schedule requires

  1. Service – Environment as a service(server) that handles functions and assets.
    1. Function – API that provides status of the working hours based on the current datetime.
    2. Asset – JSON file for the schedule.
  2. Integration with IVR

Service

Represents a service environment which handles functions and assets as well as environments and 3rd party dependencies. To create functions and assets, this is a must have.

Manage Services

Twilio Console → Functions and Assets → Services

Create a new service if needed. I named it schedule as you see on the screenshot below.

[figure 1] Service List

Once you select the service, you will see this screen.

I already added the code as a function and the JSON file as an asset.

The API endpoint is /get-schedule and the JSON file name is USSchedule.json which is named based on the combination of the country name and the filename as {country}Schedule.json.

[Figure 2] Service Layout

Function

This is an API written in Node.js.

Parameters

KeyValue
countryUS
timezoneNew_York

Response

{
  isOpen: boolean,
  isHoliday: boolean,
  isPartialDay: boolean,
  isRegularDay: boolean,
  description: string
}

Code

const axios = require('axios');
const Moment = require('moment-timezone');
const MomentRange = require('moment-range');
const moment = MomentRange.extendMoment(Moment);

exports.handler = function(context, event, callback) {
    // create Twilio Response
    let response = new Twilio.Response();
    response.appendHeader('Access-Control-Allow-Origin', '*');
    response.appendHeader('Access-Control-Allow-Methods', 'OPTIONS POST');
    response.appendHeader('Content-Type', 'application/json');
    response.appendHeader('Access-Control-Allow-Headers', 'Content-Type');

    // create default response body
    response.body = {
      isOpen: false,
      isHoliday: false,
      isPartialDay: false,
      isRegularDay: false,
      description: ''
    }

    // parameters
    const timezone = event.timezone;
    const country = event.country;

    // load JSON with schedule
    const jsonFile = `https://${context.DOMAIN_NAME}/${country}Schedule.json`;
    axios.get(jsonFile)
      .then(function (axiosResponse) {
        const schedule = axiosResponse.data;

        const currentDate = moment().tz(timezone).format('MM/DD/YYYY');

        const isHoliday = currentDate in schedule.holidays;
        const isPartialDay = currentDate in schedule.partialDays;

        if (isHoliday) {
          response.body.isHoliday = true;

          if (typeof(schedule.holidays[currentDate].description) !== 'undefined') {
            response.body.description = schedule.holidays[currentDate].description;
          }

          callback(null, response);

        } else if (isPartialDay) {
          response.body.isPartialDay = true;

          if (typeof(schedule.partialDays[currentDate].description) !== 'undefined') {
            response.body.description = schedule.partialDays[currentDate].description;
          }

          if (checkIfInRange(schedule.partialDays[currentDate].begin, schedule.partialDays[currentDate].end, timezone) === true) {
            response.body.isOpen = true;
            callback(null, response);
          } else {
            callback(null, response);
          }
        } else {
          // regular hours
          const dayOfWeek = moment().tz(timezone).format('dddd');

          response.body.isRegularDay = true;
          if (checkIfInRange(schedule.regularHours[dayOfWeek].begin, schedule.regularHours[dayOfWeek].end, timezone) === true) {
            response.body.isOpen = true;
            callback(null, response);
          } else {
            callback(null, response);
          }
        }
      })
      .catch(function (error) {
        callback(error);
      })
};

function checkIfInRange(begin, end, timezone) {
  const currentDate = moment().tz(timezone).format('MM/DD/YYYY');
  const now = moment().tz(timezone);

  const beginMomentObject = moment.tz(`${currentDate} ${begin}`, 'MM/DD/YYYY HH:mm:ss', timezone);
  const endMomentObject = moment.tz(`${currentDate} ${end}`, 'MM/DD/YYYY HH:mm:ss', timezone);
  const range = moment.range(beginMomentObject, endMomentObject);

  return now.within(range);
}

Asset/JSON file for the schedule

IMPORTANT This JSON file should be updated yearly due to the holidays and irregular hours. The schedule must be a valid JSON format. Ensure the JSON format via JSONLint. For detailed schema, refer to https://www.twilio.com/blog/advanced-schedules-studio.

{
  "holidays": {
    "09/05/2022": {
      "description": "Labor Day"
    },
    "11/24/2022": {
      "description": "Thanksgiving"
    },
    "11/25/2022": {
      "description": "Thanksgiving"
    },
    "12/26/2022": {
      "description": "End of Year"
    },
    "12/27/2022": {
      "description": "End of Year"
    },
    "12/28/2022": {
      "description": "End of Year"
    },
    "12/29/2022": {
      "description": "End of Year"
    },
    "12/30/2022": {
      "description": "End of Year"
    },
    "01/02/2023": {
      "description": "End of Year"
    }
  },
  "partialDays": {
    "09/02/2022": {
      "begin": "09:00:00",
      "end": "14:00:00",
      "description": "Labor Day Weekend"
    },
    "11/23/2022": {
      "begin": "09:00:00",
      "end": "14:00:00",
      "description": "Thanksgiving Weekend"
    }
  },
  "regularHours": {
    "Monday": {
      "begin": "09:00:00",
      "end": "17:00:00"
    },
    "Tuesday": {
      "begin": "09:00:00",
      "end": "17:00:00"
    },
    "Wednesday": {
      "begin": "09:00:00",
      "end": "17:00:00"
    },
    "Thursday": {
      "begin": "09:00:00",
      "end": "17:00:00"
    },
    "Friday": {
      "begin": "09:00:00",
      "end": "17:00:00"
    },
    "Saturday": {
      "begin": null,
      "end": null
    },
    "Sunday": {
      "begin": null,
      "end": null
    }
  }
}

We have three keys: holidays, partialDays and regularHours. On Holidays, the contact center is closed the entire day. On Partial Days, we have irregular hours, and we also include the Regular hours. The evaluation will be done from top to bottom, first we check for a holiday, then for a partial day, then finally the regular schedule.

Dependencies

The service runs with node v14. I could add the latest version of the following libraries at this moment.

  1. axios; 0.27.2
  2. moment-timezone; 0.5.37
  3. moment-range; 4.0.2

To add the dependencies, click the Dependencies link on the left bottom navigation pane as shown on [Figure 2].

Deploy

Everytime any changes are made, the Deploy All button must be hit to load everything up on the service. Refer to [Figure 2].

TIP Enabling live logs prints out all logs and errors for better debugging while testing integrations such as IVR in this case.

Integration with IVR

Put the Studio Flow and Scheduling Function together.

[Figure 3] Studio Flow

Here’s the JSON for the Studio Flow above.

{
  "description": "IVR for creating a Flex voice task",
  "states": [
    {
      "name": "Trigger",
      "type": "trigger",
      "transitions": [
        {
          "event": "incomingMessage"
        },
        {
          "next": "setTimezone",
          "event": "incomingCall"
        },
        {
          "event": "incomingConversationMessage"
        },
        {
          "event": "incomingRequest"
        },
        {
          "event": "incomingParent"
        }
      ],
      "properties": {
        "offset": {
          "x": -50,
          "y": -50
        }
      }
    },
    {
      "name": "SendCallToAgent",
      "type": "send-to-flex",
      "transitions": [
        {
          "event": "callComplete"
        },
        {
          "event": "failedToEnqueue"
        },
        {
          "event": "callFailure"
        }
      ],
      "properties": {
        "offset": {
          "x": 30,
          "y": 1450
        },
        "workflow": "WW032210848b4e15f80594499f729d5e33",
        "channel": "TC18c47c75be91221842177fa3a324f1ae",
        "attributes": "{ \"type\": \"inbound\", \"name\": \"{{trigger.call.From}}\", \"language\":\"{{widgets.WelcomeMessage.Digits}}\" }"
      }
    },
    {
      "name": "WelcomeMessage",
      "type": "gather-input-on-call",
      "transitions": [
        {
          "next": "SelectLanguage",
          "event": "keypress"
        },
        {
          "next": "SelectLanguage",
          "event": "speech"
        },
        {
          "next": "SendCallToAgent",
          "event": "timeout"
        }
      ],
      "properties": {
        "voice": "default",
        "speech_timeout": "auto",
        "offset": {
          "x": 30,
          "y": 980
        },
        "loop": 1,
        "finish_on_key": "#",
        "say": "Welcome. For English, press 1 or remain on the line. Para español, presione 2 ahora.",
        "language": "en-US",
        "stop_gather": true,
        "gather_language": "en",
        "profanity_filter": "true",
        "timeout": 5
      }
    },
    {
      "name": "SpanishNotAvailable",
      "type": "say-play",
      "transitions": [
        {
          "event": "audioComplete"
        }
      ],
      "properties": {
        "offset": {
          "x": 410,
          "y": 1450
        },
        "loop": 1,
        "say": "Los agentes españoles no están disponibles en este momento. Por favor, llámenos más tarde.",
        "language": "es-ES"
      }
    },
    {
      "name": "SelectLanguage",
      "type": "split-based-on",
      "transitions": [
        {
          "next": "SendCallToAgent",
          "event": "noMatch"
        },
        {
          "next": "SendCallToAgent",
          "event": "match",
          "conditions": [
            {
              "friendly_name": "1",
              "arguments": [
                "{{widgets.WelcomeMessage.Digits}}"
              ],
              "type": "equal_to",
              "value": "1"
            }
          ]
        },
        {
          "next": "SpanishNotAvailable",
          "event": "match",
          "conditions": [
            {
              "friendly_name": "2",
              "arguments": [
                "{{widgets.WelcomeMessage.Digits}}"
              ],
              "type": "equal_to",
              "value": "2"
            }
          ]
        }
      ],
      "properties": {
        "input": "{{widgets.WelcomeMessage.Digits}}",
        "offset": {
          "x": 30,
          "y": 1210
        }
      }
    },
    {
      "name": "setTimezone",
      "type": "set-variables",
      "transitions": [
        {
          "next": "checkSchedule",
          "event": "next"
        }
      ],
      "properties": {
        "variables": [
          {
            "value": "US",
            "key": "country"
          },
          {
            "value": "America/New_York",
            "key": "timezone"
          }
        ],
        "offset": {
          "x": 30,
          "y": 150
        }
      }
    },
    {
      "name": "checkSchedule",
      "type": "run-function",
      "transitions": [
        {
          "next": "setScheduleVariables",
          "event": "success"
        },
        {
          "event": "fail"
        }
      ],
      "properties": {
        "service_sid": "ZSe31175a27242057abee07b43a452ce75",
        "environment_sid": "ZE62f036a7f9686791ca0f64d2f1c17726",
        "offset": {
          "x": 30,
          "y": 350
        },
        "function_sid": "ZH15a1ecb3d4d7f7f041e0bfb76d426063",
        "parameters": [
          {
            "value": "{{flow.variables.country}}",
            "key": "country"
          },
          {
            "value": "{{flow.variables.timezone}}",
            "key": "timezone"
          }
        ],
        "url": "https://schedule-2226.twil.io/get-schedule"
      }
    },
    {
      "name": "setScheduleVariables",
      "type": "set-variables",
      "transitions": [
        {
          "next": "isOpen",
          "event": "next"
        }
      ],
      "properties": {
        "variables": [
          {
            "value": "{{widgets.checkSchedule.parsed.isOpen}}",
            "key": "isOpen"
          },
          {
            "value": "{{widgets.checkSchedule.parsed.isHoliday}}",
            "key": "isHoliday"
          },
          {
            "value": "{{widgets.checkSchedule.parsed.isPartialDay}}",
            "key": "isPartialDay"
          },
          {
            "value": "{{widgets.checkSchedule.parsed.isRegularDay}}",
            "key": "isRegularDay"
          },
          {
            "value": "{{widgets.checkSchedule.parsed.description}}",
            "key": "description"
          }
        ],
        "offset": {
          "x": 30,
          "y": 560
        }
      }
    },
    {
      "name": "isOpen",
      "type": "split-based-on",
      "transitions": [
        {
          "event": "noMatch"
        },
        {
          "next": "WelcomeMessage",
          "event": "match",
          "conditions": [
            {
              "friendly_name": "open",
              "arguments": [
                "{{flow.variables.isOpen}}"
              ],
              "type": "equal_to",
              "value": "true"
            }
          ]
        },
        {
          "next": "playClosedMessage",
          "event": "match",
          "conditions": [
            {
              "friendly_name": "closed",
              "arguments": [
                "{{flow.variables.isOpen}}"
              ],
              "type": "equal_to",
              "value": "false"
            }
          ]
        }
      ],
      "properties": {
        "input": "{{flow.variables.isOpen}}",
        "offset": {
          "x": 30,
          "y": 760
        }
      }
    },
    {
      "name": "playClosedMessage",
      "type": "say-play",
      "transitions": [
        {
          "event": "audioComplete"
        }
      ],
      "properties": {
        "offset": {
          "x": 410,
          "y": 980
        },
        "loop": 1,
        "say": "Sorry, we are closed. Office hours are from 9am to 5pm Monday through Friday in Eastern Time."
      }
    }
  ],
  "initial_state": "Trigger",
  "flags": {
    "allow_concurrent_calls": true
  }
}

References

  1. https://www.twilio.com/blog/advanced-schedules-studio