Monthly Archives: April 2014

Using Twilio to Add VOIP/Conference Calling to your Ext JS 4 Apps

I was recently tasked with automating a grant submission and approval workflow process for a non-profit corporation, as illustrated in the figure below. When there is disagreement among the reviewers as to whether a project should be funded, a conference call must take place between the reviewers in order to facilitate a consensus.

naccho1

The scope of the project initially dictated that we simply add-in the capability to schedule a call and send out meeting invitations. However, when it came time to actually build out this feature, I realized that we could take it one step further, “bring the awesome”, and use the Twilio API to integrate VOIP conference calling directly into the app.

Twilio is a cloud-based service that has both client-side and REST-based API’s for adding telephony and SMS features to your desktop and mobile apps. It can also bridge IP and regular voice communications.

There are three components to every Twilio app.

1) Generating a secure passkey to access the API (server-side REST)
2) Creating a UX to place calls (client-side Javascript)
3) Handling Twilio server callbacks with TwiML, the Twilio Markup Language (server-side REST)

app flow

Generating a secure passkey

You can generate a secure passkey by leveraging one of Twilio’s “Helper APIs” which include support for PHP, Ruby, Python, .NET, Java, Salesforce, Node, ColdFusion, and others. My app has access to both ColdFusion and .NET resources, so we decided to build out the initial Twilio handlers using ColdFusion as we feel that it’s a little faster for rapid prototyping. Ultimately, we may port this over to .NET which comprises the overwhelming bulk of the appserver code.

The Twilio ColdFusion helper library is located at the following URL:
https://github.com/jasonfill/ColdFusion-Twilio-Library

Using the helper library to generate a secure passkey is a relatively straightforward process (illustrated below):


<!--- 
 TwilioSettings.cfm contains the following settings:
 - Your Twilio Account ID
 - Your Twilio passcode
 - The Twilio API version that you intend to use
 - The Twilio endpoint (api.twilio.com)
--->
<cfinclude template="TwilioSettings.cfm" />

<!--- Create a new instance of the Twilio Lib, this can be stored in the App scope or elsewhere as a singleton... --->
<cfset REQUEST.TwilioLib = createObject("component", "twilio.TwilioLib").init(
   REQUEST.AccountSid, 
   REQUEST.AuthToken, 
   REQUEST.ApiVersion, 
   REQUEST.ApiEndpoint
) />

<!--- bind for outgoing calls  --->
<cfset cap = REQUEST.TwilioLib.getCapability() />

<cfset params = StructNew() />

<!--- the appsid is registered with Twilio from their Admin GUI --->
<!--- each app has a unique callback URL --->
<cfset appSid = "[insert twilio application id here]">

<!--- Allow the client to make outgoing calls by passing in the appsid --->
<cfset cap.allowClientOutgoing(appSid, params) />

<cfset token = cap.generateToken() />

<cfoutput>
<!DOCTYPE HTML>

<html>
<head>
    <meta charset="UTF-8">
    <title>ModelPractice</title>
<link rel="stylesheet" href="resources/MyApp-all.css"/>
<!--- output the security token --->
<script type="text/javascript">
  TwilioToken = "#token#";
</script>
<script type="text/javascript" src="app.js"></script>
<link rel="stylesheet" href="resources/css/app.css">
</head>
<body>
</body>
</html>
</cfoutput>

Creating a UX to place calls

Now that I had a valid security token accessible through the global Javascript variable TwilioToken, I could get on with the process of building out the user experience, depicted in the image below:

reviewers

Users can initiate a conference call by checking the boxes for with whom they wish to communicate. Once the selection is made, I needed to load the Twilio Javascript library and make the appropriate method calls. I also needed to provide an area that could display status messages as well as toggle the availability of a “hangup” button. To accomplish these tasks, I wrapped the Twilio library in a custom Ext JS class, illustrated below:

Ext.define('Ext.ux.Twilio', {

	config: {
		statusMsg: null, // ui component to display status
		ready: false,
		hangupButton: null // ui button to terminate call
	},

	constructor: function(config) {
		this.initConfig(config);
		this.callParent(arguments);
	},

	init: function(callback, scope, arg1, arg2) {

		if (!window['Twilio']) {

			var me = this;

			// load the twilio api and configure event listeners

			Ext.Loader.loadScript({

				url: '//static.twilio.com/libs/twiliojs/1.1/twilio.min.js',

				onLoad: function() {

					Twilio.Device.setup(TwilioToken, {
						debug: true
					});

					Twilio.Device.ready(function(device) {
						me.getStatusMsg().update('Ready to Connect');
						me.setReady(true);
						callback.call(scope, arg1, arg2);
						me.getHangupButton().hide();
					});

					Twilio.Device.offline(function(device) {
						me.getStatusMsg().update('Connection offline');
						me.getHangupButton().hide();
					});

					Twilio.Device.error(function(error) {
						me.getStatusMsg().update(error);
						console.log('twilio error', error);
						me.getHangupButton().hide();
					});

					Twilio.Device.connect(function(conn) {
						me.getStatusMsg().update("Successfully joined call");
						me.getHangupButton().show();
					});

					Twilio.Device.disconnect(function(conn) {
						me.getStatusMsg().update("Disconnected");
						me.getHangupButton().hide();
					});

				}


			}); // loadscript

		}
	},

	makeOutgoingCall: function(phoneNumber) {

		if (!this.getReady()) {
			this.init(this.makeOutgoingCall, this, phoneNumber);
			return;
		}

		Twilio.Device.connect({
			"PhoneNumber": phoneNumber,
			"tAction" : "placecall"
		});

	},

	// roomId is an arbitrary string that identifies
	// a unique virtual conference room

	// phoneNumbers is a comma-delimited string of 
	// telephone numbers to call and automatically 
	// add people to the conference line.
	
	makeConferenceCall: function(roomId, phoneNumbers) {

		if (!this.getReady()) {
			this.init(this.makeConferenceCall, this, roomId, phoneNumbers);
			return;
		}

		Twilio.Device.connect({
			"conferenceRoom": roomId,
			"phoneNumbers": phoneNumbers,
			"tAction" : "conferencecall"
		});
	}

});

When the user presses the conference call button, I aggregate the phone numbers from the selected records and invoke my custom Twilio class as follows:

(note that I have controller REFs that point to an Ext.Toolbar component containing an Ext.toolbar.TextItem component to display status messages and an Ext.button.Button to enable the caller to terminate the connection)

onConferenceCall: function(panel,grid) {
 
 if (!this.Twilio) {
    this.Twilio = Ext.create('Ext.ux.Twilio', {
        statusMsg : this.getTelStatusBar(),
        hangupButton: this.getHangupButton()
    });
 }

 var selectedRecords = grid.getSelectionModel().getSelection();

 if (selectedRecords.length === 0) {

  // join
  this.Twilio.makeConferenceCall('Room ' + this.surveyInstanceId,"");

 } else {

    // join and dial others
    var phoneNumbers = [];
    for (var i=0; i<selectedRecords.length; i++) {
        // ignore current user
        if (selectedRecords[i].get('reviewerId') != MyApp.app.userCredentials.id) {
            if (!Ext.isEmpty(selectedRecords[i].get('phone'))) {
                phoneNumbers.push(selectedRecords[i].get('phone'));
            }
        }
    }
    this.Twilio.makeConferenceCall('Room ' + this.surveyInstanceId,phoneNumbers.join(','));

 }
}

Handling Twilio REST Callbacks

When the Twilio.Device.connect() method is invoked, the Twilio service accesses a file on my server that provides additional directives via TwiML- Twilio’s XML language. Our application was relatively straightforward – we either needed to dial a single phone number (taction = placecall) or join a conference call (taction=conferencecall). In the case of joining a conference call, I also wanted the moderator to be able to have Twilio proactively call reviewers and add them to the virtual conference room. The “TwiML” application is listed below. Note that while any arguments passed to the Twilio.Device.connect() method will automatically be transmitted to your TwiML app, when you’re invoking the Twilio REST api’s custom passthrough arguments must be enumerated specifically as illustrated on line 58.


<cfsetting enablecfoutputonly="true">

<!--- your Twilio Account ID --->
<cfset accountSid = "[insert account sid here]">

<!--- your "secret" Twilio authorization token --->
<cfset authToken = "[insert auth token here]">

<!--- 
 The caller id phone number that you register 
 with Twilio
--->
<cfset callerIdNumber="[insert caller id number here]">

<cfif taction is "placecall">
  <cfsavecontent variable="out">
   <cfoutput><Number>#PhoneNumber#</Number></cfoutput>
  </cfsavecontent>
  <cfcontent type="text/xml"><cfoutput><?xml version="1.0" encoding="UTF-8"?>
    <Response>
    <Say>Placing your call now.</Say>
    <Dial callerId="#callerIdNumber#">#out#</Dial>
  </Response></cfoutput>
</cfif>


<cfif taction is "conferencecall">

	<cfcontent type="text/xml"><cfoutput><?xml version="1.0" encoding="UTF-8"?>
	<Response>
		<Say>Connecting to the Conference. You will hear music until another reviewer arrives.</Say>
		<Dial callerId="#callerIdNumber#">
		   <Conference>#ConferenceRoom#</Conference>
		</Dial>
	</Response></cfoutput>

	<cfif (isdefined("phoneNumbers"))>
	  <cfset i = 0>
	  <cfloop list="#phonenumbers#" index="thisPhoneNumber">
	      <cfset i=i+1>
		  
		  <cfthread phoneNumber="#thisPhoneNumber#" 
					name="conferenceOtherUser#i#"
					conferenceRoom = "#conferenceRoom#"
					accountSid="#accountSid#"
					authToken="#authToken#"
					callerIdNumber="#callerIdNumber#">
					
			 <cfhttp 
					url="https://api.twilio.com/2010-04-01/Accounts/#accountSid#/Calls"
					port="443"
					method="post"
					username="#accountSid#"
					password="#authtoken#">
					
					<cfhttpparam name="Caller" type="formfield" value="#callerIdNumber#">
					<cfhttpparam name="Called" type="formfield" value="#phoneNumber#">
					<cfhttpparam name="Url" type="formfield" value="http://www.naccho.org/mpdev/VoiceRequest.cfm?taction=conferencecall&conferenceRoom=#urlencodedformat(conferenceRoom)#">
					
					<cfhttpparam name="IfMachine" type="formfield" value="Hangup">
					
			  </cfhttp>	
			  
			  <cflog file="twilio1" text="#cfhttp.filecontent#">
		  </cfthread>
	  
	  </cfloop>

	</cfif>

</cfif>

So what are you waiting for? Add VOIP/Telephony/SMS capabilities to your apps and “bring the awesome!”

Generate an Excel File from a Tree Panel / Tree Grid!

Recently I was tasked with building an application whereby the user could export report data contained within a ┬átreegrid to Microsoft Excel. Under normal circumstances, I would have used a server-side approach using ColdFusion’s robust functionality. However, in this particular case, we were using .NET and frankly, I wanted the middleware developer on the project to stay focused on building the core .NET CRUD webservices that were required for the project.

treegrid

Here’s the first-pass at a solution, which I implemented as an override to the tree panel control. Calling it’s rather quite simple. Just invoke the tree panel’s downloadExcelXml() method.

 {
   xtype: 'button',
   flex: 1,
   text: 'Download to Excel',
   handler: function(b, e) {
     b.up('treepanel').downloadExcelXml();
   }
 }

And here’s the conversion of the tree store data to an Excel spreadshet…

Ext.define('MyApp.view.override.TreePanel', {
	override: 'Ext.tree.Panel',
	requires: 'Ext.form.action.StandardSubmit',

	/*
        Kick off process
    */

	downloadExcelXml: function(includeHidden, title) {

		if (!title) title = this.title;

		var vExportContent = this.getExcelXml(includeHidden, title);

		var location = 'data:application/vnd.ms-excel;base64,' + Base64.encode(vExportContent);

		/* 
          dynamically create and anchor tag to force download with suggested filename 
          note: download attribute is Google Chrome specific
        */

		if (Ext.isChrome || Ext.isGecko || Ext.isSafari) { // local download
			var gridEl = this.getEl();

			var el = Ext.DomHelper.append(gridEl, {
				tag: "a",
				download: title + "-" + Ext.Date.format(new Date(), 'Y-m-d Hi') + '.xls',
				href: location
			});

			el.click();

			Ext.fly(el).destroy();

		} else { // remote download

			var form = this.down('form#uploadForm');
			if (form) {
				form.destroy();
			}
			form = this.add({
				xtype: 'form',
				itemId: 'uploadForm',
				hidden: true,
				standardSubmit: true,
				url: 'http://webapps.figleaf.com/dataservices/Excel.cfc?method=echo&mimetype=application/vnd.ms-excel&filename=' + escape(title + ".xls"),
				items: [{
					xtype: 'hiddenfield',
					name: 'data',
					value: vExportContent
				}]
			});

			form.getForm().submit();
		}
	},

	/*

        Welcome to XML Hell
        See: http://msdn.microsoft.com/en-us/library/office/aa140066(v=office.10).aspx
        for more details

    */
	getExcelXml: function(includeHidden, title) {

		var theTitle = title || this.title;

		var worksheet = this.createWorksheet(includeHidden, theTitle);
		var totalWidth = this.columns.length;

		return ''.concat(
			'<?xml version="1.0"?>',
			'<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet" xmlns:html="http://www.w3.org/TR/REC-html40">',
			'<DocumentProperties xmlns="urn:schemas-microsoft-com:office:office"><Title>' + theTitle + '</Title></DocumentProperties>',
			'<OfficeDocumentSettings xmlns="urn:schemas-microsoft-com:office:office"><AllowPNG/></OfficeDocumentSettings>',
			'<ExcelWorkbook xmlns="urn:schemas-microsoft-com:office:excel">',
			'<WindowHeight>' + worksheet.height + '</WindowHeight>',
			'<WindowWidth>' + worksheet.width + '</WindowWidth>',
			'<ProtectStructure>False</ProtectStructure>',
			'<ProtectWindows>False</ProtectWindows>',
			'</ExcelWorkbook>',

			'<Styles>',

			'<Style ss:ID="Default" ss:Name="Normal">',
			'<Alignment ss:Vertical="Bottom"/>',
			'<Borders/>',
			'<Font ss:FontName="Calibri" x:Family="Swiss" ss:Size="12" ss:Color="#000000"/>',
			'<Interior/>',
			'<NumberFormat/>',
			'<Protection/>',
			'</Style>',

			'<Style ss:ID="title">',
			'<Borders />',
			'<Font ss:Bold="1" ss:Size="18" />',
			'<Alignment ss:Horizontal="Center" ss:Vertical="Center" ss:WrapText="1" />',
			'<NumberFormat ss:Format="@" />',
			'</Style>',

			'<Style ss:ID="headercell">',
			'<Font ss:Bold="1" ss:Size="10" />',
			'<Alignment ss:Horizontal="Center" ss:WrapText="1" />',
			'<Interior ss:Color="#A3C9F1" ss:Pattern="Solid" />',
			'</Style>',


			'<Style ss:ID="even">',
			'<Interior ss:Color="#CCFFFF" ss:Pattern="Solid" />',
			'</Style>',


			'<Style ss:ID="evendate" ss:Parent="even">',
			'<NumberFormat ss:Format="yyyy-mm-dd" />',
			'</Style>',

			'<Style ss:ID="evenint" ss:Parent="even">',
			'<Numberformat ss:Format="0" />',
			'</Style>',

			'<Style ss:ID="evenfloat" ss:Parent="even">',
			'<Numberformat ss:Format="0.00" />',
			'</Style>',

			'<Style ss:ID="odd">',
			'<Interior ss:Color="#CCCCFF" ss:Pattern="Solid" />',
			'</Style>',

			'<Style ss:ID="groupSeparator">',
			'<Interior ss:Color="#D3D3D3" ss:Pattern="Solid" />',
			'</Style>',

			'<Style ss:ID="odddate" ss:Parent="odd">',
			'<NumberFormat ss:Format="yyyy-mm-dd" />',
			'</Style>',

			'<Style ss:ID="oddint" ss:Parent="odd">',
			'<NumberFormat Format="0" />',
			'</Style>',

			'<Style ss:ID="oddfloat" ss:Parent="odd">',
			'<NumberFormat Format="0.00" />',
			'</Style>',

			'<Style ss:ID="indent1even" ss:Parent="odd">',
			'<Alignment ss:Horizontal="Left" ss:Vertical="Bottom" ss:Indent="1"/>',
			'</Style>',

			'<Style ss:ID="indent2even" ss:Parent="odd">',
			'<Alignment ss:Horizontal="Left" ss:Vertical="Bottom" ss:Indent="2"/>',
			'</Style>',

			'<Style ss:ID="indent3even" ss:Parent="odd">',
			'<Alignment ss:Horizontal="Left" ss:Vertical="Bottom" ss:Indent="3"/>',
			'</Style>',

			'<Style ss:ID="indent4even" ss:Parent="odd">',
			'<Alignment ss:Horizontal="Left" ss:Vertical="Bottom" ss:Indent="4"/>',
			'</Style>',

			'<Style ss:ID="indent1odd" ss:Parent="odd">',
			'<Alignment ss:Horizontal="Left" ss:Vertical="Bottom" ss:Indent="1"/>',
			'</Style>',

			'<Style ss:ID="indent2odd" ss:Parent="odd">',
			'<Alignment ss:Horizontal="Left" ss:Vertical="Bottom" ss:Indent="2"/>',
			'</Style>',

			'<Style ss:ID="indent3odd" ss:Parent="odd">',
			'<Alignment ss:Horizontal="Left" ss:Vertical="Bottom" ss:Indent="3"/>',
			'</Style>',

			'<Style ss:ID="indent4odd" ss:Parent="odd">',
			'<Alignment ss:Horizontal="Left" ss:Vertical="Bottom" ss:Indent="4"/>',
			'</Style>',


			'</Styles>',
			worksheet.xml,
			'</Workbook>'
		);
	},


	getData: function() {

		var pnl = this;
		var cols = [];
		var maxDepth = 0;

		// get columns

		for (var i = 0; i < pnl.columns.length; i++) {
			cols.push({
				dataIndex: pnl.columns[i].dataIndex,
				title: pnl.columns[i].text,
				xtype: pnl.columns[i].xtype
			});
		}

		var aResult = [];
		var rootNode = pnl.getRootNode();

		rootNode.cascadeBy(function(node) {
			var rec = {};
			for (var j = 0; j < cols.length; j++) {
				rec[cols[j].dataIndex] = node.get(cols[j].dataIndex);
			}
			rec.depth = node.getDepth();
			if (rec.depth > maxDepth) {
				maxDepth = rec.depth;
			}
			aResult.push(rec);
		}, this);


		return {
			cols: cols,
			maxDepth: maxDepth,
			data: aResult
		}


	},



	createWorksheet: function(includeHidden, theTitle) {
		// Calculate cell data types and extra class names which affect formatting

		var data = this.getData();

		var cellType = [];
		var cellTypeClass = [];

		var totalWidthInPixels = 0;
		var colXml = '';
		var headerXml = '';
		var visibleColumnCountReduction = 0;

		for (var i = 0; i < data.cols.length; i++) {
			colXml += '<Column ss:AutoFitWidth="1"/>';
			headerXml += '<Cell ss:StyleID="headercell">' + '<Data ss:Type="String">' + data.cols[i].title + '</Data>' + '<NamedCell ss:Name="Print_Titles"></NamedCell></Cell>';
			switch (data.cols[i].xtype) {
				case "numbercolumn":
					cellType.push("Number");
					cellTypeClass.push("int");
					break;
				case "booleancolumn":
					cellType.push("String");
					cellTypeClass.push("");
					break;
				case "datecolumn":
					cellType.push("DateTime");
					cellTypeClass.push("date");
					break;
				default:
					cellType.push("String");
					cellTypeClass.push("");
					break;
			}
		}


		var visibleColumnCount = data.cols.length;

		var result = {
			height: 9000,
			// width: Math.floor(totalWidthInPixels * 30) + 50
			width: 1000
		};

		// Generate worksheet header details.

		// determine number of rows
		var numGridRows = data.data.length + 1;



		// create header for worksheet
		var t = ''.concat(
			'<Worksheet ss:Name="' + theTitle + '">',

			'<Names>',
			'<NamedRange ss:Name="Print_Titles" ss:RefersTo="=\'' + theTitle + '\'!R1:R2">',
			'</NamedRange></Names>',

			'<Table ss:ExpandedColumnCount="' + (visibleColumnCount + 2),
			'" ss:ExpandedRowCount="' + numGridRows + '" x:FullColumns="1" x:FullRows="1" ss:DefaultColumnWidth="65" ss:DefaultRowHeight="15">',
			colXml,
			'<Row ss:Height="38">',
			'<Cell ss:MergeAcross="' + (visibleColumnCount - 1) + '" ss:StyleID="title">',
			'<Data ss:Type="String" xmlns:html="http://www.w3.org/TR/REC-html40">',
			'<html:b>' + theTitle + '</html:b></Data><NamedCell ss:Name="Print_Titles">',
			'</NamedCell></Cell>',
			'</Row>',
			'<Row ss:AutoFitHeight="1">',
			headerXml,
			'</Row>'
		);

		// Generate the data rows from the data in the Store
		var groupVal = "",
			cellClass = null,
			v = null;
		
		for (var i = 1; i < data.data.length; i++) {

			cellClass = (i & 1) ? 'odd' : 'even';
			t += '<Row>';
			

			for (var j = 0; j < data.cols.length; j++) {
				v = data.data[i][data.cols[j].dataIndex];

				if (j == 0 && data.data[i].depth > 1) {
					// first col might be indented
				    t += '<Cell ss:StyleID="indent' + data.data[i].depth + cellClass + '"><Data ss:Type="' + cellType[j] + '">';
				} else {
					t += '<Cell ss:StyleID="' + cellClass + cellTypeClass[j] + '"><Data ss:Type="' + cellType[j] + '">';
				}

				
				if (cellType[j] == 'DateTime') {
					t += Ext.Date.format(v, 'Y-m-d');
				} else {
					t += v;
				}
				t += '</Data></Cell>';


			}
			t += '</Row>';
		}

		result.xml = t.concat(
			'</Table>',
			'<WorksheetOptions xmlns="urn:schemas-microsoft-com:office:excel">',
			'<PageLayoutZoom>0</PageLayoutZoom>',
			'<Selected/>',
			'<Panes>',
			'<Pane>',
			'<Number>3</Number>',
			'<ActiveRow>2</ActiveRow>',
			'</Pane>',
			'</Panes>',
			'<ProtectObjects>False</ProtectObjects>',
			'<ProtectScenarios>False</ProtectScenarios>',
			'</WorksheetOptions>',
			'</Worksheet>'
		);

		return result;
	}
});

The server-side code used to echo the generated spreadsheet back to the browser and force a “download” operation is the following:

<cfcomponent>

 <cffunction name="echo" access="remote" returntype="void">
  
  <cfargument name="mimetype" type="string" required="no" default="text/html">
  <cfargument name="filename" type="string" required="yes">
  <cfargument name="data" type="string" required="no" default="">

  <cfif isdefined("form.data")>
   <cfset arguments.data = form.data>
  </cfif>

  <cfheader name="Content-Disposition" value="attachment; filename=#arguments.filename#">
  
  <cfcontent type="#arguments.mimetype#"><cfoutput>#arguments.data#</cfoutput>

 </cffunction>

</cfcomponent>

Note that the ColdFusion-based “echo” webservice is available for evaluation/testing purposes only. No warranty or level of service is expressed or implied.

You can play around with the code on Sencha Fiddle:
https://fiddle.sencha.com/#fiddle/4pr

Go check it out!