Oh my god. It's full of code!

Posts tagged “IVR

Angel IVR REST API wrapper for Salesforce Apex

Hey all,

Just a random post to help out any developers who may be trying to use the Angel IVR outbound calling features of their new REST API. This is a wrapper class that should do all the hard work for you. It handles all the HTTP traffic, batching, parsing of responses and serialization for ya. You’ll need to create a custom setting called Angel IVR Site and store your API token, API endpoint, subscriber Id in there (or just change the references to settings.whatever in the code to be hard coded. The test class shows creation of one of these objects, along with the expected fields names.

Here is the code. I’ll probably post up a sample app later and maybe even an installable package. I just wanted to get this out there before I forget, or get too lazy to do anything else with it.

/*Angel IVR API Wrapper
Description: Simple class for placing calls via the Angel IVR REST API. Also has experemental
implementations of the other API calls, including cancel, and request (gets job status)

See
https://www.socialtext.net/ivrwiki/outbound_rest_api_documentation
for API details.

Author: Daniel Llewellyn (Twitter: @Kenji776)
Date: 11/16/2012
*/

global class angelIVRWrapper
{
    class applicationException extends Exception {}

    public Angel_IVR_Site__c settings = Angel_IVR_Site__c.getValues('Prod');
    global static boolean isTest = false;

    //a single call item to place to angel. You will always pass a list of these. To make calls create a list
    //of these things (one for each person you wish to call if the call has variables unique to each person, or a single callitem with all the phone numbers included if
    //they are not unique). Then pass them into the campaignCall function along with the angel site you wish to use.
    global class callItem
    {
        public integer maxWaitTime = 30;
        public string phoneNumbers = '';
        public map<string,string> variables = new map<string,string>();
    }

    //a generic API response container. Will contain any error messages and status codes if something bombs.
    //otherwise it should contain a jobId you can later use for cancelling, checking status, etc. Also contains
    //a list of all the call items we attempted to place calls from, all the call items that where skipped (due to being invalid for some resaon)
    //and a list of the responses as provided by angel.
    global class callResponse
    {
        public string jobId = '';
        public string message = 'Calls Placed Successfully';
        public integer httpResponseCode = 200;
        public string httpResponse = 'ok';
        public boolean success = true;
        public list<callRequest> callRequestResponses = new list<callRequest>();
        public list<callItem> placedRequests = new list<callItem>();
        public list<callItem> skippedRequests = new list<callItem>();
    }

    //return object type from the API that contains details about a single call placed using the outbound API.
    global class callRequest
    {
        public string code = '';
        public string callStartTime = '';
        public string callEndTime = '';
        public string phonenumber='';
        public string phoneLineRequestID='';
        public string message = '';
    }    

    @RemoteAction
    global static list<callResponse> campaignCall(list<callitem> callItems, string angelSite)
    {
        angelIVRWrapper controller = new angelIVRWrapper();
        return controller.campaignCall(callItems , angelSite, true);
    }

    //wrapper for the campaignCall function of the Angel IVR. Pass it a list of call items, one for each person you wish to call.
    //it will return a call response object which should contain the job Id you can use for getting the status later.
    public list<callResponse> campaignCall(list<callitem> callItems, string angelSite, boolean allowPartial)
    {
        list<FeedItem> posts = new list<FeedItem>();
        set<string> phoneNumbers = new set<string>();

        //experemental idea for breaking a large number of call items into batches, delayed
        //by a number of seconds. This will likely have to use a recursive scheduled job or something.
        integer batchDelaySeconds = 600;

        list<callResponse> res = new list<callResponse>();
        map<string,string> params = new map<string,string>();
        params.put('allowPartial',string.valueOf(allowPartial));     

        //ask the broker to make a call to angel using the campaignCalls method, passing in the list of call items, and set the allowPartial 
        //url param as well.
        integer counter = 0;
        list<callItem> thisBatch = new list<callItem>();
        list<callItem> skippedRequests = new list<callItem>();

        //break the overall list into batches of 250, since that is the maximum amount you can place in one request.
        for(callItem callItem : callItems)
        {  
            counter++;
            if(callItem.variables.size() <= 50 && !phoneNumbers.contains(callItem.phoneNumbers))
            {              
                thisBatch.add(callItem); 
                phoneNumbers.add(callItem.phoneNumbers);

                if(callitem.variables.containsKey('ID'))
                {
                    FeedItem post = new FeedItem();
                    post.ParentId = callitem.variables.get('ID'); 
                    post.Body = 'Placed outbound call to this record using phone number '+callItem.phoneNumbers+' to Angel site ' + angelSite;    
                    posts.add(post);        
                }                       
            }
            //if this call item has too many variables, or we already have a call for this phone number in the queue, add the call item to our list
            //of skipped calls.
            else
            {
                skippedRequests.add(callItem);
            }

            if(thisBatch.size() == 250 || counter == callItems.size())
            {
                callResponse thisResponse = brokerRequest('POST','campaignCalls',thisBatch, angelSite, params);
                thisResponse.skippedRequests.addAll(skippedRequests);
                res.add(thisResponse);  
                thisBatch.clear();  
                skippedRequests.clear();    
            }
        }
        insert posts;
        return res;
    }

    //wrapper for the requests function of the Angel IVR. Pass it a job id and get back the current status of that job.
    public callResponse requests(string jobId)
    {        
        //ask the broker to make a call to angel using the campaignCalls method, passing in the list of call items, and set the allowPartial 
        //url param as well.  
        callResponse res = brokerRequest('GET','requests/job/'+jobId,null,null,null);        
        return res;
    } 

    //wrapper for the cancels function of the Angel IVR. Pass it a job id and that job will be cancelld if it is currently queued.
    public callResponse cancels(string jobId)
    {
        //ask the broker to make a call to angel using the campaignCalls method, passing in the list of call items, and set the allowPartial 
        //url param as well.  
        callResponse res = brokerRequest('GET','cancels/job/'+jobId,null,null,null);        
        return res;    
    }

    //handles the actual sending of http requests, handling of the response, formatting, etc.
    public callResponse brokerRequest(string httpVerb, string method, list<callitem> callitems, string angelSite, map<string,string> urlParams)
    {
        //create a call response object to pass back.
        callResponse callResponse = new callResponse();

        string requestURI;
        //create the endpoint URI with a bit of string concatination.
        if(angelSite !=null)
        {
            requestURI = settings.API_Endpoint__c+'/'+settings.Subscriber_ID__c+'/'+angelSite+'/'+method+'?apiKey='+settings.API_Key__c;
        }
        else
        {
            requestURI = settings.API_Endpoint__c+'/'+settings.Subscriber_ID__c+'/'+method+'?apiKey='+settings.API_Key__c;
        }    
        HttpRequest req = new HttpRequest();
        //setup the http request.
        req.setMethod(httpVerb);  
        req.setHeader('Content-Type','application/xml');     

        if(urlParams !=null)
        {
            for(string param : urlParams.keySet())
            {
                requestURI += '&'+param+'='+urlParams.get(param);
            }
        }
        req.setEndpoint(requestURI);

        if(callItems != null)
        {    
            //generating Angel XML using the serializer and set that as the request body.                  
            req.setBody(serializeCallItemAngelXml(callitems));
        }
        //send http request
        Http http = new Http();
        HttpResponse res = new HttpResponse();
        string responseBody;
        try
        {
            //some handling for if this is a test class or not. Can't make outbound calls in tests, so we need a mock response
            //if its a test.
            if(!isTest)
            {
                res = http.send(req); 
                responseBody = res.getBody();
            }
            else
            {
                responseBody = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><outboundRequest jobID="0a14021a-1c-13b0b0b3e8a-7abc2f20-d09"><callRequest><requestDetails number="9522206974" phoneLineRequestID="200158136763"/><attempt callEndTime="" callStartTime="" code="queued"><message>queued</message></attempt></callRequest><timeCreated>2012-11-16T16:06:24.331-05:00</timeCreated></outboundRequest>';           
            }

            //if the http response doesn't have a 200 response code, an error happened, so we gotta toss an error, set the success to false, etc.
            //trying to parse whatever is in the body of the http request would probably cause an error as well, so we want to avoid doing that.

            if(res.getStatusCode() != 200 && !isTest)
            {
                throw new applicationException('Error during HTTP Request. Status Code:' + res.getStatusCode() + ' Status:' + res.getStatus() + ' Body Text: ' + res.getBody());
            }

            //if all has gone well until now, parse the results of the http request into a callResponse object and pass that back. This will
            //contain the job id and status of the call(s)
            callResponse = deserializeAngelOutboundRequestXML(responseBody);          
        }
        catch(exception e)
        {          
            callResponse.success = false;
            callResponse.message = e.getMessage() + ' on line: ' + e.getLineNumber() + '. Root cause: ' + e.getCause();   
            callResponse.httpResponseCode = res.getStatusCode();   
            callResponse.httpResponse = res.getStatus();

        }   
        callResponse.placedRequests.addAll(callItems);             
        return callResponse;           
    }

    //takes a list of callItem objects and turns them into valid AngelXML to send to the API. I wish
    //this was more dynamic (using some kind of reflection to iterate over the object properties, but whatever).
    //remember you can include variables in the callItem object to customize the information sent to Angel for each 
    //particular call.
    public static string serializeCallItemAngelXml(list<callitem> callitems)
    {
        string angelXML = '<callItems>';

        for(callitem thisCallItem : callItems)
        {
            angelXML += '<callItem>';
            angelXML += '<maxWaitTime>' + thisCallItem.maxWaitTime + '</maxWaitTime>';
            angelXML += '<phoneNumbers>' + thisCallItem.phoneNumbers + '</phoneNumbers>';
            for(string thisVar : thisCallItem.variables.keySet())
            {
                angelXML += '<variables><name>'+thisVar+'</name>';
                angelXML += '<value>'+thisCallItem.variables.get(thisVar)+'</value></variables>';
            }
            angelXML += '</callItem>';
        }

        angelXML += '</callItems>';
        return angelXML;
    }

    public static callResponse deserializeAngelOutboundRequestXML(string angelXMLResponse)
    {
        Xmlstreamreader reader = new Xmlstreamreader(angelXMLResponse);

        callResponse thisResponse = new callResponse();
        callRequest thisRequest;

        while (reader.hasNext()) 
        { 
            if(reader.getEventType() == XmlTag.START_ELEMENT && reader.getLocalName() == 'outboundRequest')
            {
                thisResponse.jobId = reader.getAttributeValue(null,'jobID'); 
            }  
            if(reader.getEventType() == XmlTag.START_ELEMENT && reader.getLocalName() == 'callRequest')
            {
                thisRequest = new callRequest();
            }            
            else if(reader.getEventType() == XmlTag.START_ELEMENT && reader.getLocalName() == 'requestDetails')
            {
                thisRequest.phonenumber = reader.getAttributeValue(null,'number');
                thisRequest.phoneLineRequestID = reader.getAttributeValue(null,'phoneLineRequestID');
            }
            else if(reader.getEventType() == XmlTag.START_ELEMENT && reader.getLocalName() == 'attempt')
            {
                thisRequest.callStarttime = reader.getAttributeValue(null,'callStartTime');
                thisRequest.callEndtime = reader.getAttributeValue(null,'callEndTime');
                thisRequest.code = reader.getAttributeValue(null,'code');
            }

            else if(reader.getEventType() == XmlTag.START_ELEMENT && reader.getLocalName() == 'message')
            {
                reader.next();
                thisRequest.message = getDecodedString(reader);
            }
            else if(reader.getEventType() == XmlTag.END_ELEMENT && reader.getLocalName() == 'attempt')
            {
                thisResponse.callRequestResponses.add(thisRequest);
            }            
            reader.next();
        }

        return thisResponse;
    }

    public static String getDecodedString(Xmlstreamreader reader)
    {
        return EncodingUtil.urlDecode(reader.getText(), 'UTF-8').trim();
    }

    @isTest
    public static void angelIVRWrapper()
    {
        isTest = true;

        //weird workaround to avoid mixed dml error, as seen here
        //http://boards.developerforce.com/t5/Apex-Code-Development/DML-not-allowed-on-user-in-test-context/m-p/98393
        User thisUser = [ select Id from User where Id = :UserInfo.getUserId() ];
            System.runAs ( thisUser ) {
            Angel_IVR_Site__c settings = new Angel_IVR_Site__c();
            settings.name = 'Prod';
            settings.Angel_Site_Id__c = 'test';
            settings.API_Endpoint__c = 'http://www.test.com';
            settings.API_Key__c = 'test api key';
            settings.Subscriber_ID__c = '402342';
            insert settings;
        }

        angelIVRWrapper controller = new angelIVRWrapper();
        list<angelIVRWrapper.callitem> callItems = new list<angelIVRWrapper.callitem>();
        Respondent__c testRespondent = testDataGenerator.createTestRespondent();

        angelIVRWrapper.callitem thisCallItem = new angelIVRWrapper.callItem();
        thisCallItem.phoneNumbers = '5555555555';
        thisCallItem.variables.put('RESPONDENT__R_NAME','Frank Jones');
        thisCallItem.variables.put('ID',testRespondent.id);

        callItems.add(thisCallItem);

        callResponse requestStatus = controller.requests(response[0].jobId);

        callResponse sendCancel = controller.cancels(response[0].jobId);

        list<callResponse> sendCalls = angelIVRWrapper.campaignCall(callItems, '200000124604');
    }
}

I Hate The Angel.com IVR API

This is a venting post. Very little useful information will be contained within. I guess perhaps it can act as a warning, but honestly, I’m just mad. It’s mostly meant to be cathartic, and perhaps a bit entertaining.

Here is the deal, Angel.com is an IVR provider. They let you make phone calls and set up call sites, etc. They offer an outbound API, which allows you of course, to make calls programmatically. Sounds good in theory. The problem is, I don’t think they really know how to build an API. Here is a quick outline of how I arrived at this conclusion.

1) Limited amount of interactions per API Token
For some reason Angel.com has decided it is wise to only allow a certain amount of API interactions per token. You use your credentials to get a token that is supposed to be valid for a given time period, however it is also only available for like 10 transactions before you need to get a new one. This adds completely unnecessary complexity to working with them. Pretty much every other vendor I have worked with gives you a token that is valid for a time period so you know when you need to get a new one. In this case I have to keep track of how many things I’ve done lest I end up over my limit and have my next call fail. This is lame.

2) Two types of call identifiers
This may be more personal preference than a legitimate gripe, but it does make things more sucky. Of course in a phone system calls can be inbound or outbound. Angel.com has two unique Id’s depending which kind of call it was. Of course this gets complicated because say you use the API to place a call, they give you back an outbound GUID (globally unique identifier), fine. Then someone answers that call and they enter your voice site, now it has an inbound GUID as well. Which one do you use for further transactions? Well apparently the outbound but that was just found out via trial and error as it isn’t documented anywhere.

3) Absolutely terrible getstatus call.
Once a call is placed I need to go back and figure out what happened with it, if it was answered, how long it took etc, so the call time can be billed back to the client. Of course I’ve had to build this complex logging system because Angel doesn’t really have any mechanisms to track which calls pertain to which client. So I have to use the getStatus API call and an outbound GUID (or a list of them), to get the data I need to build my log. This function, is completely, hands down, god awful, for a few reasons.

– Archaic return type: Instead of just having the useful information as text, it’s all returned in these crazy function wrappers. You have to spend half an hour inspecting the data that is returned and playing guess and check to see how to get the data you want. Why, why do I have to do this? What is wrong with returning simple text?

– Very fragile: If even one GUID in the list you send them is invalid for some reason, the whole list aborts. It’s completely all or nothing, meaning you have to get it perfect every time, which wouldn’t be so bad except….

– Random reporting of invalid GUID: For some reason, beyond any comprehension often times I get this error. Angel claims that one or more of the GUIDs I give them do not belong to me. Then where the fuck did I get them smart guy? You think I just conjured some fucking GUID’s out of thin air? Pulled them out of my ass and thought it would be funny for you to try and parse them? No you slack jawed backwards ass API, you GAVE THEM TO ME. I placed the call using the API, recorded the GUID in my database and now I’m trying to figure out what happened. Don’t you tell me it’s invalid, you gave it to me. Fine though, if you say it’s bad I’ll remove it from my list and pretend it doesn’t exist, well I’d love to except…

– Horrible error data reporting: If you get the above error, good luck trying to figure out what GUID is causing the problem. They give you the list of them, sure… but it’s just in a string.. after some error text, part of a stack trace and wrapped in some HTML formatting. That’s right… HTML formatting, in a web service call error message return. Not only do they not return an array with each GUID and it’s status, no, you get one text blob with the GUID’s that are presumable the problem just kind of… hanging out in there. Just chilling among the human readable error, the stack track and some mother fucking HTML.

annot perform web service invocation getStatus.The fault returned when invoking the web service operation is:<br> <pre>AxisFault faultCode: {http://schemas.xmlsoap.org/soap/envelope/}Server faultSubcode: faultString: Some of the CallGUIDs that you have submitted do not belong to you. SubscriberID: 40225 token: 0a14022d-0b-132634f962b-685b305c-ccb@40225@403e4852@1315926152748@1@1cef1c329a519114c4922915bbca946b GUID: [list of GUIDs here]

What the fuck is this shit?

GUID’s don’t want to be in that mosh pit, they belong as their own data structure that I can iterate over easily so I can mark that I shouldn’t try to get them again. Even after parse the text blob, cleaning the HTML, extracting the GUIDs and attempting to update my database so that I don’t attempt to get those statuses again, then I get this error…

– stackTrace:The email entered does not match our records. – WHAT. THE. FUCK. What the hell is this error? Email does not match your records? My credentials are hard coded in. These credentials just worked moments ago. Why now, all of a sudden as if being randomly smote by Thor does this error crop up? No real explanation, no reasoning, just error. Your credentials are bad, but yet good enough to get this far? My credentials half work? They exist in some kind of quantum super position of working and not working at the same time. Suck my ass.

So that’s about it. The main reasons I hate Angel. Apparently they have some new REST API which I’m sure they’ll tout as being better, but that doesn’t do me a ton of good seeing as I’ll have to rewrite my entire integration library to use it, and I have no guarantee it doesn’t suck just as hard.

Whew, I feel a little better now.


Coldfusion, Angel.com, Google Maps Directions, and You!

Okay, so even the title is a mouthful, this post is probably going to be insane you are thinking. Well… maybe, but it’s cool stuff. So picture this. You are using Angel.com as an IVR provider. So people call in and talk to a phone machine for data. Now say you want to give directions over the phone. Say you want those directions to be dynamic, and given to the user step by step. So for example you are hosting an event. People pre-register for this event, and have provided their address, which you have stored in a database. Bob Johnson calls in (he has registered and provided his address before) and wants directions from his house to your event center. You might think you’d need a live person to do this. Blasphemy! Have Bob authenticate so we can find his address in the database. Feed that address into an Angel.com variable (if there were a better way to enter addresses over the phone, you wouldn’t even need to pre-register, but because entering data in phones sucks we kind of need their address to already exist somewhere we can get it). Once that variable is in Angel.com, pass it, along with the destination to this tool via URL arguments. This tool will then give step by step directions that the IVR can read aloud back to Bob. He even has the ability to replay each direction, and navigate backward and forward through the steps.

Just copy and paste this and run host it on a ColdFusion server somewhere. It’s ready to be called with all configs just being passed in the URL at runtime.

<cfparam name="url.start" type="string" default="1405+Olive+Ln+N,+Plymouth,+Hennepin,+Minnesota+55447">
<cfparam name="url.end" type="string" default="1111+Cambridge+St.+Hopkins,+MN+55343+(White+Castle)">
<cfparam name="url.stepID" type="integer" default="1">

<!---- The id of the page that calls this webservice in angel.com ---->
<cfparam name="url.thisPage" type="string" default="1">

<!---- the id of the page to go to if this thing errors for some reason --->
<cfparam name="url.failPage" type="string" default="2">

<!---- the id of the page to go when we are all done giving directions ---->
<cfparam name="url.nextPage" type="string" default="3">

<!---- the id of the page to go to if we just can't find directions or a route ---->
<cfparam name="url.reEnterInfoPage" type="string" default="4">

<!---- the id of the page to go to if the person decides they want to talk to a person ---->
<cfparam name="url.transferToCC" type="string" default="5">

<!---- the id of the page to go to if the person wants to hang up---->
<cfparam name="url.disconnectPage" type="string" default="6">

<cfparam name="XMLData" type="string" default="">
<cfparam name="text" type="string" default="">

<!--- Format the directions for sending to google --->
<cfset url.start = replacenocase(url.start, " ", "+")>
<cfset url.end = replacenocase(url.end, " ", "+")>

<cfhttp url="http://maps.google.com/" result="KMLData">
    <cfhttpparam name="saddr" value="#url.start#" type="url">
    <cfhttpparam name="daddr" value="#url.end#" type="url">
    <cfhttpparam name="output" value="kml" type="url">
</cfhttp> 


<cfoutput>
    <cftry>
        <cfset XMLData = xmlParse(KMLData.FileContent)>
        <cfset totalNumberOfSteps = arraylen(XMLData.kml.Document.XmlChildren)-4>
        <cfset text = XMLData.kml.Document.XmlChildren[stepID+3].XmlChildren[1].XmlText>
        <cfset text = text&" . .">
        <!--- Some extra text formatting for reading over the IVR. You can easily add more abbreviations here if there
              are some I forgot --->
        <cfset text = replacenocase(text, " LN ", " Lane ")>
        <cfset text = replacenocase(text, " BLVD ", " Boulevard ")>
        <cfset text = replacenocase(text, " RD ", " Road ")>
        <cfset text = replacenocase(text, " ST ", " Street ")>
        <cfset text = replacenocase(text, " Ave ", " Avenue ")>
        <cfset text = replacenocase(text, " NW ", " North West ")>
        <cfset text = replacenocase(text, " NE ", " North East ")>
        <cfset text = replacenocase(text, " SE ", " South East ")>
        <cfset text = replacenocase(text, " SW ",  "South West")>            
        <cfset text = replacenocase(text, " N ", " North ")>
        <cfset text = replacenocase(text, " E ", " East ")>
        <cfset text = replacenocase(text, " W ", " West ")>
        <cfset text = replacenocase(text, " S ",  "South ")>

        
        <cfsavecontent variable="PromptMessage">
            #text#
            <cfif stepID LT totalNumberOfSteps>
                Press 1 for the next direction. Press 2 to repeat this direction. Press 3 to hear the previous direction.
                <cfelse>
                    You have reached your destination.
                    Press 1 to disconnect. Press 2 to repeat this direction. Press 3 to hear the previous direction.
            </cfif>    
        </cfsavecontent>
    
        <cfif stepID LT totalNumberOfSteps>
            <cfsavecontent variable="XMLLinks">
                <LINK dtmf="1" returnValue="#stepID+1#" destination="#url.thisPage#" />
                <LINK dtmf="2" returnValue="#stepID#" destination="#url.thisPage#" />
                <LINK dtmf="3" returnValue="#stepID-1#" destination="#url.thisPage#" />
            </cfsavecontent>
            
            <cfelse>
                <cfsavecontent variable="XMLLinks">
                    <LINK dtmf="1" returnValue="#url.nextPage#" destination="#url.thisPage#" />
                    <LINK dtmf="2" returnValue="#stepID#" destination="#url.thisPage#" />
                    <LINK dtmf="3" returnValue="#stepID-1#" destination="#url.thisPage#" />
                </cfsavecontent>            
        </cfif>
        
        
        <cfset counter = 1>
        <cfset VariablesObject[counter] = structnew()>
        <cfset VariablesObject[counter]["Name"] = "totalDirections">
        <cfset VariablesObject[counter]["Value"] = totalNumberOfSteps>

         

        <cfcatch type="any">
            <cfset promptMessage = "Sorry we couldn't find a route with the information supplied. Press 1 to try a different direction method. Press 2 to disconnect, or press 3 to be transferred to customer care.">
            <cfsavecontent variable="XMLLinks">
                <LINK dtmf="1" returnValue="#reEnterInfoPage#" destination="#failPage#" />
                <LINK dtmf="2" returnValue="#disconnectPage#" destination="#url.thisPage#" />
                <LINK dtmf="3" returnValue="#transferToCC#" destination="#url.thisPage#" />
            </cfsavecontent>        
            

            <cfset VariablesObject[1] = structnew()>
            <cfset VariablesObject[1]["Name"] = "ErrorType">
            <cfset VariablesObject[1]["Value"] = cfcatch.Type>        

            <cfset VariablesObject[2] = structnew()>
            <cfset VariablesObject[2]["Name"] = "ErrorMessage">
            <cfset VariablesObject[2]["Value"] = cfcatch.Message>

            <cfset VariablesObject[3] = structnew()>
            <cfset VariablesObject[3]["Name"] = "ErrorDetails">
            <cfset VariablesObject[3]["Value"] = cfcatch.Detail>                                    
        </cfcatch>
    </cftry>    
    
    <cfset ReturnObject = printQuestionReturnVariables('stepID',PromptMessage,XMLLinks,url.failPage,VariablesObject)>
#trim(ReturnObject)#
</cfoutput>


<cffunction name="printQuestionReturnVariables" access="remote" hint="Print Question Data For Angel IVR with returnable variables">
    <cfargument name="varName" default="none" type="string">
    <cfargument name="promptMessage" default="none" type="string">
    <cfargument name="linkMessage" default="none" type="string">
    <cfargument name="failPage" default="failPagePlaceholder" type="string">
    
    <!--- This is an array of structures, with keys "name" and "value" --->
    <!--- EX variables[1].Name = Gender --->
    <!--- EX variables[1].Value = Male --->
    <cfargument name="variablesToInclude" default="" type="any" required="no">
    
    <!--- create, scope and set the loop counter variable used below --->
    <cfset var i = 0>
    
    <cfoutput>
        <cfsavecontent variable="ReturnMessage">
            <ANGELXML>
                <QUESTION var="#ucase(varname)#">
                    <PLAY>
                        <PROMPT type="text">
                            #promptMessage#
                        </PROMPT>
                    </PLAY>
                    <RESPONSE>
                        <KEYWORD>
                            #linkMessage#
                        </KEYWORD>
                    </RESPONSE>
    
                    <ERROR_STRATEGY type="nomatch" reprompt="true">
                        <PROMPT type="text"> Sorry I did not get that. </PROMPT>
                        <PROMPT type="text"> I still did not get that. </PROMPT>
                        <PROMPT type="text"> Since I am having so much trouble; please hold while I transfer you to a customer representative who can better serve you. </PROMPT>
                        <GOTO destination="/25" />
                    </ERROR_STRATEGY>
                    <ERROR_STRATEGY type="noinput" reprompt="true">
                        <PROMPT type="text"> Sorry I did not get that. </PROMPT>
                        <PROMPT type="text"> I still did not get that. </PROMPT>
                        <PROMPT type="text"> Since I am having so much trouble; please hold while I transfer you to a customer representative who can better serve you. </PROMPT>
                        <GOTO destination="/25" />
                    </ERROR_STRATEGY>
                </QUESTION>        
                <cfif IsArray(arguments.variablesToInclude)>
                    <VARIABLES>
                        <cfloop from="1" to="#arraylen(arguments.variablesToInclude)#" index="i">
                            <cftry>
                                <cfif structkeyexists(arguments.variablesToInclude[i],'Name') and  structkeyexists(arguments.variablesToInclude[i],'value')>
                                    <VAR name="#ucase(arguments.variablesToInclude[i]['Name'])#" value="#arguments.variablesToInclude[i]['Value']#" />
                                </cfif>
                                <cfcatch type="any">
                                    <cfset ErrorData = structnew()>
                                    <cfset ErrorData.Error = cfcatch>
                                    <cfset ErrorData.Arguments = arguments>
                                    <cfset ErrorData.form = form>
                                    
                                    <!--- You might wanna email this data to yourself or something --->
                                 
                                </cfcatch>    
                            </cftry>                            
                        </cfloop>

                    </VARIABLES>
                </cfif>    
                
            </ANGELXML>
        </cfsavecontent>
    </cfoutput>
    
    <cfreturn ReturnMessage>
</cffunction>

So really what’s happening here is that the page gets called with some URL variables. Those variables are used to construct a google maps http request. That request actually prints out XML. We take the XML and clean it up a little bit and format it. Then print it off in a nice Angel XML package so it can be read by the system. The page just provides one step at a time, and uses a recursive style setup to just continually give directions until there are no more.

If you just want to use the step by step direction giving, you can of course easily remove all the Angel XML junk and just access the #return# variable and do whatever you like with it. This could easily be adapted to an online direction giver, or for Twilio, SMS, whatever. For now it is ColdFusion based, but I may try and convert it to an Apex class in the not too distant future. Depending on how the user is interacting with this, you could even remove the need to have the address pre stored. You could for example have a dedicated number where users just text their current address and you text them back step by step directions to your office or whatever. Really the sky is the limit here.

Here is a sample to see how it works. The demo has some extra stuff to make it more “person usable” instead of stripped down to be consumed by a computer.
Example of ColdFusion/Google Maps/Angel.com dynamic directions

Anyway, hope you guys think this is cool. It was a lot of fun to write!