Creating Outgoing Dialers with UCMA 3.0

Summary:   This article describes design considerations for building a dialer application by using Microsoft Unified Communications Managed API (UCMA) 3.0. The sample dialer application shows how a dialer is built for Microsoft Lync Server 2010.

Applies to:   Microsoft Lync Server 2010 | Microsoft Unified Communications Managed API (UCMA) 3.0 Core SDK

Published:   October 2011 | Provided by:   Michael Greenlee, Clarity Consulting, Inc. | About the Author

Contents

  • Introduction

  • Prerequisites

  • What the Dialer Does

  • Startup and Shutdown

  • OutboundCallSession Class

  • Placing the Outbound Call

  • Retrying Rejected Calls

  • Recognizing Voice Mail Messages

  • Reporting on Outbound Calls

  • Code Listing 1: OutboundSchedulerSample Class

  • Code Listing 2: OutboundCallSession Class

  • Conclusion

  • Additional Resources

Introduction

From time to time, a contact center or other business unit has to place many outgoing calls to customers or sales prospects as part of what is usually called an outgoing dialing campaign. Organizations that are using Microsoft Lync Server 2010 may decide to execute these dialing campaigns. Microsoft Unified Communications Managed API (UCMA) 3.0 makes it fairly easy to create server applications which can perform these tasks using Lync Server 2010. This article describes some of the considerations in building this kind of dialer application with UCMA, and uses a sample dialer application to show how a dialer can be built for Lync Server 2010.

Prerequisites

This article assumes that readers have a basic familiarity with UCMA 3.0 and understand how to write an application which can register an endpoint with Lync Server 2010 and answer calls. For more information about UCMA development, see Additional Resources.

In addition, before trying the sample code in this article, you should use Lync Server Management Shell to provision a trusted application pool, trusted application, and trusted application endpoint. Also, ensure the Microsoft Unified Communications Managed API (UCMA) 3.0 Core SDK is installed in your development environment. For more information about the provisioning process, see Additional Resources.

What the Dialer Does

Outbound dialing applications can be complex, but there are certain features which almost any dialer must have. This article describes how to implement the following features using UCMA 3.0.

  • Call progress analysis

  • Scheduling

  • Reporting

Call Progress Analysis

Outbound dialers typically use something called call progress analysis, which in simple terms is a process for recognizing whether a call has reached a human. Call progress analysis consists of two parts:

  • Pre-connect information, which consists of whether the call has connected successfully and, if not, what caused it to fail (a busy signal, for example).

  • Post-connect information, which consists of whether the call was answered by a human, an answering machine, or something else.

Call progress analysis serves several purposes in outgoing dialing campaigns. First, it helps organizations make the most efficient use of agents by connecting them to calls only when a human is on the line. It also helps meet the regulations that are applied to outgoing dialing campaigns in many jurisdictions.

Call Scheduling and Reporting

Another function that is generally performed by outgoing dialers is scheduling of calls. For instance, if a call does not go through on the first try, the dialer can schedule it to be retried after a certain period of time has elapsed. When live agents are involved, dialers may also limit the number of calls that are initiated at the same time so that there will not be too many telephone calls for the agents to handle. This is especially important because in some countries there can be penalties for placing calls where an agent is not actually available to speak to the person being called.

Supervisors also typically like to know the results of a dialing campaign. Therefore, it is important for the dialer to track the end state of each call, and potentially some simple metrics such as how long the call took.

Startup and Shutdown

Before the application can place any outgoing calls, it has to start and establish an application endpoint. The steps in starting a UCMA application and establishing an endpoint are beyond the scope of this article, and there are many resources that are available for readers who have to learn how to do this. However, the next code example can be used as a starting point for the dialer application. Besides performing these basic startup tasks, it also reads some configuration settings from the App.config file into memory, and parses the SIP URIs which are meant to be dialed into a generic list of strings.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.Rtc.Collaboration;
using System.Configuration;
using Microsoft.Rtc.Signaling;
using System.Threading;
using Microsoft.Rtc.Collaboration.AudioVideo;

namespace OutboundScheduler
{
    internal class OutboundSchedulerSample
    {
        CollaborationPlatform _platform;
        ApplicationEndpoint _endpoint;
        TimerWheel _timerWheel = new TimerWheel(512, new TimeSpan(0, 0, 15));

        string _applicationId;
        string _recipientSipUri;
        List<string> _urisToDial = new List<string>();

        readonly List<OutboundCallSession> _sessions = new List<OutboundCallSession>();
        int _completedOrFailedSessions = 0;

        ManualResetEvent _startupWaitHandle =
            new ManualResetEvent(false);

        internal void Start()
        {
            _applicationId = ConfigurationManager.AppSettings["applicationId"];
            _recipientSipUri = ConfigurationManager.AppSettings["recipientSipUri"];
            string urisToDialString = ConfigurationManager.AppSettings["numbersToDial"];

            _urisToDial = urisToDialString.Split('|').ToList();

            // Use auto-provisioning to load trusted application settings
            ProvisionedApplicationPlatformSettings platformSettings =
                new ProvisionedApplicationPlatformSettings("OutboundScheduler",
                _applicationId);

            _platform = new CollaborationPlatform(platformSettings);

            try
            {
                // Register for information on trusted application endpoints
                _platform.RegisterForApplicationEndpointSettings(
                    OnApplicationEndpointDiscovered);

                // Start up the collaboration platform
                _platform.BeginStartup(
                    startupAsyncResult =>
                    {
                        try
                        {
                            _platform.EndStartup(startupAsyncResult);
                            Console.WriteLine("Platform started.");
                        }
                        catch (RealTimeException ex)
                        {
                            Console.WriteLine(ex);
                        }
                    }, null);
            }
            catch (InvalidOperationException ex)
            {
                Console.WriteLine(ex);
            }
        }

        void OnApplicationEndpointDiscovered(object sender,
            ApplicationEndpointSettingsDiscoveredEventArgs e)
        {
            _endpoint = new ApplicationEndpoint(_platform,
                e.ApplicationEndpointSettings);

            Console.WriteLine("Endpoint {0} discovered.",
                e.ApplicationEndpointSettings.OwnerUri);

            // Establish the application endpoint
            _endpoint.BeginEstablish(establishAsyncResult =>
            {
                try
                {
                    _endpoint.EndEstablish(establishAsyncResult);
                    Console.WriteLine("Endpoint established.");

                    _startupWaitHandle.Set();
                }
                catch (RealTimeException ex)
                {
                    Console.WriteLine(ex);
                }
            },
            null);
        }

        internal void WaitForStartup()
        {
            _startupWaitHandle.WaitOne();
        }

        internal void Stop()
        {
            try
            {
                _endpoint.BeginTerminate(
                    terminateAsyncResult =>
                    {
                        try
                        {
                            _endpoint.EndTerminate(terminateAsyncResult);
                            Console.WriteLine("Terminated endpoint.");

                            ShutDownPlatform();
                        }
                        catch (RealTimeException ex)
                        {
                            Console.WriteLine(ex);
                        }
                    }, null);
            }
            catch (InvalidOperationException ex)
            {
                Console.WriteLine(ex);
            }
        }

        private void ShutDownPlatform()
        {
            try
            {
                _platform.BeginShutdown(shutdownAsyncResult =>
                {
                    try
                    {
                        _platform.EndShutdown(shutdownAsyncResult);
                        Console.WriteLine("Shut down platform.");
                    }
                    catch (RealTimeException ex)
                    {
                        Console.WriteLine(ex);
                    }
                },
                null);
            }
            catch (InvalidOperationException ex)
            {
                Console.WriteLine(ex);
            }
        }

       
    }
}

OutboundCallSession Class

In building any application that deals with multiple calls at the same time, whether incoming or outgoing, it is useful to create a separate class which is responsible for individual calls and contains the call handling logic and call state. In this sample application, this class is called OutboundCallSession. A basic skeleton of the class together with some methods for logging appears in the next example.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.Rtc.Collaboration;
using Microsoft.Rtc.Collaboration.AudioVideo;
using Microsoft.Rtc.Signaling;
using System.Diagnostics;
using Microsoft.Speech.Synthesis;
using System.Threading;

namespace OutboundScheduler
{
    class OutboundCallSession
    {
        LocalEndpoint _endpoint;
        string _uriToDial;
        string _id = Guid.NewGuid().ToString();

        ResultType _resultType = ResultType.None;

        OutboundCallSessionState _state = OutboundCallSessionState.Idle;

        TimerItem _timer;

        int _retryCount = 0;

        public string Id
        {
            get { return _id; }
            set { _id = value; }
        }

        internal OutboundCallSessionState State
        {
            get { return _state; }
        }

        internal ResultType ResultType
        {
            get { return _resultType; }
            set { _resultType = value; }
        }

        internal event EventHandler<OutboundCallSessionStateChangedEventArgs> StateChanged;

        internal OutboundCallSession(LocalEndpoint endpoint, TimerItem timer, string uriToDial)
        {
            _endpoint = endpoint;
            _timer = timer;
            _uriToDial = uriToDial;
        }

        void Log(object obj)
        {
            StringBuilder builder = new StringBuilder();
            builder.AppendFormat("[URI {0}] - ",  _uriToDial);
            builder.Append(obj);
            Console.WriteLine(builder.ToString());
        }

        void Log(string str, params object[] items)
        {
            StringBuilder builder = new StringBuilder();
            builder.AppendFormat("[URI {0}] - ",  _uriToDial);
            builder.AppendFormat(str, items);
            Console.WriteLine(builder.ToString());
        }

        void HandleStateChange(OutboundCallSessionState newState)
        {
            OutboundCallSessionState oldState = _state;

            _state = newState;

            if (StateChanged != null)
            {
                StateChanged(this, 
                    new OutboundCallSessionStateChangedEventArgs()
                    {
                        PreviousState = oldState,
                        State = newState
                    }
                );
            }

            Log("State changed to {0}", newState);
        }
    }

    internal enum ResultType
    {
        None,
        HumanAnswered,
        VoicemailAnswered,
        ExceededRetryLimit,
        Failure
    }

    internal enum OutboundCallSessionState
    {
        Idle,
        Dialing,
        Answered,
        Completed,
        WaitingForRetry,
        Failed
    }

    internal class OutboundCallSessionStateChangedEventArgs : EventArgs
    {
        internal OutboundCallSessionState PreviousState { get; set; }
        internal OutboundCallSessionState State { get; set; }
    }
}

There are some points worth noting here. First, the session class has a State property which exposes the current state of the outgoing dialing session. The HandleStateChange method changes the state while also reporting the state change to any subscribers to the StateChanged event. This event can be used later to allow the application to monitor sessions for reporting. The ResultType property is also used for reporting. ResultType indicates what happened on the call, whether it was answered and, if so, whether a human or a voice mailbox answered.

Note

There are several methods for logging that appear later in this article with call handling code.

Placing the Outbound Call

The session class needs code to start the outgoing call. As shown next, this code goes in the Dial method..

internal void Dial()
{
    try
    {
        Conversation conv = new Conversation(_endpoint);
        _call = new AudioVideoCall(conv);
        Log("Calling...");
        HandleStateChange(OutboundCallSessionState.Dialing);

        // Establish the outgoing call.
        _call.BeginEstablish(_uriToDial, 
            new CallEstablishOptions() { MaximumEstablishTime = new TimeSpan(0, 1, 0) },
            ar =>
            {
                try
                {
                    _call.EndEstablish(ar);

                    Log("Call established.");
                    HandleStateChange(OutboundCallSessionState.Answered);
                }
                catch (RealTimeException ex)
                {
                    HandleFinalFailure(ex);
                }
            },
            null);
    }
    catch (InvalidOperationException ex)
    {
        Log(ex);
    }
}

This method creates a new Conversation object and an AudioVideoCall object to go alongside it. It then establishes the call by using the BeginEstablish method, which specifies a destination URI. The AudioVideoCall object is stored in an instance variable, which has to be declared at the class level:

AudioVideoCall _call;

Now that the Dial method is in place, the application code can be modified to actually start a telephone call for each specified SIP URI. A modified OnApplicationEndpointDiscovered method for OutboundSchedulerSample appears in the next example. The new code, creates a new instance of OutboundCallSession for each SIP URI that was specified in configuration.

void OnApplicationEndpointDiscovered(object sender,
    ApplicationEndpointSettingsDiscoveredEventArgs e)
{
    _endpoint = new ApplicationEndpoint(_platform,
        e.ApplicationEndpointSettings);

    Console.WriteLine("Endpoint {0} discovered.",
        e.ApplicationEndpointSettings.OwnerUri);

    // Establish the application endpoint
    _endpoint.BeginEstablish(establishAsyncResult =>
    {
        try
        {
            _endpoint.EndEstablish(establishAsyncResult);
            Console.WriteLine("Endpoint established.");

            // Create a new outgoing dialing session
            // for each of the URIs.
            foreach (string uri in _urisToDial)
            {
                OutboundCallSession session = new OutboundCallSession(_endpoint,
                    new TimerItem(_timerWheel, new TimeSpan(0, 3, 0)), uri);
                _sessions.Add(session);

                session.Dial();
            }

            _startupWaitHandle.Set();
        }
        catch (RealTimeException ex)
        {
            Console.WriteLine(ex);
        }
    },
    null);
}

Note

The TimerItem object that is passed into the constructor for OutboundCallSession is used to retry failed calls.

At this point, it would be possible to run the application and have it establish an outgoing telephone call for each SIP URI. Any calls that result in a busy signal or no answer will throw an exception, which will be written to the console. If the called party answers, or a voice mail system answers, there will be nothing but silence on the call.

Retrying Rejected Calls

Inevitably, a certain percentage of calls will not go through because the line is busy, because of a technical problem, or because there is no answer on the other end. If the number is truly unreachable, disconnected, or invalid, there is no reason to retry the call. If, however, the condition that prevented the call from going through is temporary (as with a busy signal), it makes sense to try again after some time has passed.

Identifying Opportunities to Retry

The first step is to verify whether the failure is a fatal failure so that there is no point in retrying, and when the call was not answered. Inspecting the exception details that appear in the next example can help reveal this information.

// Establish the outgoing call.
_call.BeginEstablish(_uriToDial, 
    new CallEstablishOptions() { MaximumEstablishTime = new TimeSpan(0, 1, 0) },
    ar =>
    {
        try
        {
            _call.EndEstablish(ar);

            Log("Call established.");
            HandleStateChange(OutboundCallSessionState.Answered);
        }
        catch (FailureResponseException frex)
        {
            Log("Failure response: {0}", frex.ResponseData.ResponseCode);

            // If the failure code indicates a busy signal, retry.
            if (frex.ResponseData.ResponseCode == ResponseCode.BusyHere ||
                frex.ResponseData.ResponseCode == ResponseCode.BusyEverywhere)
            {
                Log("Busy...retrying in 3 minutes.");
                HandleNoAnswer();
            }
            else
            {
                HandleFinalFailure(frex);
            }
        }
        catch (OperationTimeoutException)
        {
            Log("No answer...retrying in 3 minutes.");
            HandleNoAnswer();
        }
        catch (RealTimeException ex)
        {
            HandleFinalFailure(ex);
        }
    },
    null);

If the failure is fatal, the application calls HandleFinalFailure and the session is terminated. If, however the call fails because of a failure response that indicates a busy signal, or if the dialed party does not answer within the time-out period, the application calls HandleNoAnswer to schedule a retry. It must then wait for some time before dialing again. The TimerWheel class, part of UCMA, can be used to track of these retry delays.

Using a TimerWheel for Retry Periods

Because an outgoing dialer may have a substantial number of outgoing calls in play at the same time, the application may have to keep track of many retry timers at the same time. Functionally, it is definitely possible to use an instance of the Timer class from the System.Threading namespace for each of these retry timers. However, UCMA also includes a class that is named TimerWheel which provides a scalable, high-performance method of scheduling many timers at the same time.

Essentially, the TimerWheel class puts the individual timers (represented by TimerItem objects) into buckets, which it calls sectors, according to when they expire. It then checks all the TimerItem objects in the same sector for expiration before proceeding to the next sector. For more information about the TimerWheel class, see Additional Resources.

When you create a new OutboundCallSession object, the application provides it with an instance of TimerItem with an expiration time of three minutes. If the session has to wait for a retry, it hooks up an event handler to the Expired event on the TimerItem instance, and then calls TimerItem.Start. This causes the TimerItem object to be scheduled. In the event handler, OnRetryTimerExpired, the application calls the Dial method, starting the process over again.

To ensure that the session does not retry indefinitely, the HandleNoAnswer method also increments a counter that keeps track of retry attempts. When the counter reaches 4, the session will terminate instead of retrying another time.

The following code shows the retry process.

private void HandleNoAnswer()
{
    _call = null;

    int retryCount = Interlocked.Increment(ref _retryCount);

    // Retry up to three times.
    if (retryCount <= 3)
    {
        Log("Retry #{0} in 3 minutes...", retryCount);
        HandleStateChange(OutboundCallSessionState.WaitingForRetry);
        WaitForRetry();
    }
    else
    {
        Log("Exceeded maximum retry count.");
        _resultType = ResultType.ExceededRetryLimit;
        HandleStateChange(OutboundCallSessionState.Failed);
    }
}

private void WaitForRetry()
{
    // Use the TimerItem to wait for 3 minutes.
    _timer.Expired += new EventHandler(OnRetryTimerExpired);
    _timer.Start();
}

void OnRetryTimerExpired(object sender, EventArgs e)
{
    _timer.Expired -= OnRetryTimerExpired;
    Dial();
}

Recognizing Voice Mail Messages

Once a call does connect, the dialer has to figure out whether a human has answered (in which case it might transfer to a live agent) or whether it has reached a voice mailbox. This can be a complex task, and there are complete software packages dedicated to distinguishing voice mail messages from humans. Some organizations may want to purchase one of these packages and integrate it with the UCMA outgoing dialer.

This sample application uses a very simplified approach. It examines the length of the first utterance on the call. When a human answers, this will typically be a short utterance, only several seconds long, such as “Hello,” or “Good morning, Doctor Thompson’s office.” A voice mail message, on the other hand, is typically longer, for example, “Hi, you have reached 425-555-0150. Please leave a message after the beep and we will return your call.”

In order to do this, the application uses the Recorder class. Recorder has an event called VoiceActivityChanged, which is invoked when someone on the call starts speaking or stops speaking. By starting a counter when the party on the other end begins speaking, and looking at the time on the counter when the speech stops, the application can make a decently accurate determination of whether it has reached a human or a voice mail system.

To add this in, the first step is another modification to the Dial method.

// Establish the outgoing call.
_call.BeginEstablish(_uriToDial, 
    new CallEstablishOptions() { MaximumEstablishTime = new TimeSpan(0, 1, 0) },
    ar =>
    {
        try
        {
            _call.EndEstablish(ar);

            Log("Call established.");
            HandleStateChange(OutboundCallSessionState.Answered);

            // Start a recorder to track the voice
            // activity on the other end of the call.
            _recorder = new Recorder();
            _recorder.VoiceActivityChanged += new EventHandler<VoiceActivityChangedEventArgs>(OnVoiceActivityChanged);
            _recorder.AttachFlow(_call.Flow);
            string tempFileName = string.Format("temp-{0}.wma", Guid.NewGuid());
            WmaFileSink sink = new WmaFileSink(tempFileName);
            _recorder.SetSink(sink);
            _recorder.Start();

            Log("Recorder attached");
        }
        catch (FailureResponseException frex)
        {
            Log("Failure response: {0}", frex.ResponseData.ResponseCode);

            // If the failure code indicates a busy signal, retry.
            if (frex.ResponseData.ResponseCode == ResponseCode.BusyHere ||
                frex.ResponseData.ResponseCode == ResponseCode.BusyEverywhere)
            {
                Log("Busy...retrying in 3 minutes.");
                HandleNoAnswer();
            }
            else
            {
                HandleFinalFailure(frex);
            }
        }
        catch (OperationTimeoutException)
        {
            Log("No answer...retrying in 3 minutes.");
            HandleNoAnswer();
        }
        catch (RealTimeException ex)
        {
            HandleFinalFailure(ex);
        }
    },
    null);

The new code creates an instance of Recorder and subscribes to the VoiceActivityChanged event. It then attaches the Recorder to the AudioVideoFlow associated with the audio call, and starts to record. In order for the VoiceActivityChanged event to be invoked, the Recorder object must actually be recording, which means that it is necessary to create a temporary audio file to hold the recording. This can be deleted later.

The next example shows code for the OnVoiceActivityChanged event handler.

void OnVoiceActivityChanged(object sender, VoiceActivityChangedEventArgs e)
{
    Log("Voice activity changed to {0}", e.IsVoice);
    if (e.IsVoice && !_stopwatch.IsRunning)
    {
        // Start the stopwatch when
        // there is voice activity.
        _stopwatch.Start();
    }
    if (!e.IsVoice && _stopwatch.IsRunning)
    {
        // When the voice activity stops, 
        // check how long it lasted.
        _recorder.VoiceActivityChanged -= OnVoiceActivityChanged;
        _stopwatch.Stop();         
        _recorder.Stop();
        _recorder.DetachFlow();
        _recorder = null;

        Log("Initial utterance was {0} milliseconds.", _stopwatch.ElapsedMilliseconds);

        // If the first utterance was longer than 4 seconds,
        // assume it is a voice mail message.
        if (_stopwatch.ElapsedMilliseconds > 4000)
        {
            Log("voicemail");
            _resultType = ResultType.VoicemailAnswered;

            HandleVoicemailAnsweredCall();
        }
        else
        {
            Log("human");
            _resultType = ResultType.HumanAnswered;

            HandleHumanAnsweredCall();
        }
    }
}

If the voice activity has started, and the stopwatch is not running yet, the event handler starts the stopwatch. If the voice activity has stopped, and the stopwatch is running, it detaches the recorder, unsubscribes the event handler, and examines the time that has elapsed on the stopwatch. If it is more than four seconds, it assumes that the call was answered by voice mail. Otherwise, it assumes there is a human on the line.

At this point, a dialer application would probably take several different actions depending on the circumstances, possibly playing a brief recording, and transferring to a live agent if one is available and if a human is connected to the call. The sample application only plays a text-to-speech message indicating whether it believes it has reached a human or a voice mailbox, and marks the session completed.

private void HandleVoicemailAnsweredCall()
{
    Speak("You are a voicemail box.");
    Log("Speaking voicemail message...");
}

private void HandleHumanAnsweredCall()
{
    Speak("You are a human.");
    Log("Speaking human message...");
}

private void Speak(string text)
{
    // Attach the SpeechSynthesisConnector to the call.
    _connector.AttachFlow(_call.Flow);

    // Point the SpeechSynthesizer to the audio stream
    // exposed by the connector.
    _synthesizer.SetOutputToAudioStream(_connector.Stream, new Microsoft.Speech.AudioFormat.SpeechAudioFormatInfo(16000,
        Microsoft.Speech.AudioFormat.AudioBitsPerSample.Sixteen, Microsoft.Speech.AudioFormat.AudioChannel.Mono));

    // Start accepting speech synthesis for the call.
    _connector.Start();

    // Speak the text.
    _synthesizer.SpeakCompleted += new EventHandler<SpeakCompletedEventArgs>(OnSpeakCompleted);
    _synthesizer.SpeakAsync(text);
}

void OnSpeakCompleted(object sender, SpeakCompletedEventArgs e)
{
    Log("Speech completed.");

    // Tear down the speech components.
    _connector.Stop();
    _synthesizer.SpeakCompleted -= OnSpeakCompleted;
    _synthesizer.Dispose();
    _connector.DetachFlow();
    _synthesizer = null;
    _connector = null;

    HandleStateChange(OutboundCallSessionState.Completed);
}

Reporting on Outbound Calls

At this point, the dialer is fairly sophisticated considering how little code it has required to build it. However, there is no easy way to tell the results of all the calls without reading and interpreting the individual log messages. It would be useful to have a summary showing the overall results of the dialing campaign as soon as the dialer has completed all calls.

The next code example shows a modification to the OnApplicationEndpointDiscovered method in OutboundSchedulerSample to track state changes in OutboundCallSession objects.

// Create a new outgoing dialing session
// for each of the URIs.
foreach (string uri in _urisToDial)
{
    OutboundCallSession session = new OutboundCallSession(_endpoint,
        new TimerItem(_timerWheel, new TimeSpan(0, 3, 0)), uri);
    session.StateChanged += new EventHandler<OutboundCallSessionStateChangedEventArgs>(OnSessionStateChanged);
    _sessions.Add(session);

    session.Dial();
}

The application can keep track of how many calls are completed (either by failing, by retrying up to the maximum retry count, or by successfully connecting) and, as soon as them all are complete, it can total up the result codes from all of the sessions, as shown in the following event handler:

void OnSessionStateChanged(object sender, OutboundCallSessionStateChangedEventArgs e)
{
    // When a session finishes, increment the completed count.
    // When the completed count reaches the total number of sessions,
    // you are done.
    if (e.State == OutboundCallSessionState.Failed ||
        e.State == OutboundCallSessionState.Completed)
    {
        int newCompletedCount = Interlocked.Increment(ref _completedOrFailedSessions);

        if (newCompletedCount >= _sessions.Count)
        {
            Console.WriteLine("All sessions have completed or failed.");
            Console.WriteLine("Human answered: {0}", _sessions.Count(s => s.ResultType == ResultType.HumanAnswered));
            Console.WriteLine("Voicemail answered: {0}", _sessions.Count(s => s.ResultType == ResultType.VoicemailAnswered));
            Console.WriteLine("Exceeded max retries: {0}", _sessions.Count(s => s.ResultType == ResultType.ExceededRetryLimit));
            Console.WriteLine("Failed: {0}", _sessions.Count(s => s.ResultType == ResultType.Failure));
            Console.WriteLine("Press enter to shut down.");
        }
    }
}

In a full-fledged dialer application, other metrics, such as average call time or average answer time, could be added to this report, and it could even be broken down by time of day, area code, or other groupings.

Code Listing 1: OutboundSchedulerSample Class

OutboundSchedulerSample Class

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.Rtc.Collaboration;
using System.Configuration;
using Microsoft.Rtc.Signaling;
using System.Threading;
using Microsoft.Rtc.Collaboration.AudioVideo;

namespace OutboundScheduler
{
    internal class OutboundSchedulerSample
    {
        CollaborationPlatform _platform;
        ApplicationEndpoint _endpoint;
        TimerWheel _timerWheel = new TimerWheel(512, new TimeSpan(0, 0, 15));

        string _applicationId;
        string _recipientSipUri;
        List<string> _urisToDial = new List<string>();

        readonly List<OutboundCallSession> _sessions = new List<OutboundCallSession>();
        int _completedOrFailedSessions = 0;

        ManualResetEvent _startupWaitHandle =
            new ManualResetEvent(false);

        internal void Start()
        {
            _applicationId = ConfigurationManager.AppSettings["applicationId"];
            _recipientSipUri = ConfigurationManager.AppSettings["recipientSipUri"];
            string urisToDialString = ConfigurationManager.AppSettings["numbersToDial"];

            _urisToDial = urisToDialString.Split('|').ToList();

            // Use auto-provisioning to load trusted application settings
            ProvisionedApplicationPlatformSettings platformSettings =
                new ProvisionedApplicationPlatformSettings("OutboundScheduler",
                _applicationId);

            _platform = new CollaborationPlatform(platformSettings);

            try
            {
                // Register for information on trusted application endpoints
                _platform.RegisterForApplicationEndpointSettings(
                    OnApplicationEndpointDiscovered);

                // Start up the collaboration platform
                _platform.BeginStartup(
                    startupAsyncResult =>
                    {
                        try
                        {
                            _platform.EndStartup(startupAsyncResult);
                            Console.WriteLine("Platform started.");
                        }
                        catch (RealTimeException ex)
                        {
                            Console.WriteLine(ex);
                        }
                    }, null);
            }
            catch (InvalidOperationException ex)
            {
                Console.WriteLine(ex);
            }
        }

        void OnApplicationEndpointDiscovered(object sender,
            ApplicationEndpointSettingsDiscoveredEventArgs e)
        {
            _endpoint = new ApplicationEndpoint(_platform,
                e.ApplicationEndpointSettings);

            Console.WriteLine("Endpoint {0} discovered.",
                e.ApplicationEndpointSettings.OwnerUri);

            _endpoint.RegisterForIncomingCall<AudioVideoCall>(oninc);

            // Establish the application endpoint
            _endpoint.BeginEstablish(establishAsyncResult =>
            {
                try
                {
                    _endpoint.EndEstablish(establishAsyncResult);
                    Console.WriteLine("Endpoint established.");

                    // Create a new outgoing dialing session
                    // for each of the URIs.
                    foreach (string uri in _urisToDial)
                    {
                        OutboundCallSession session = new OutboundCallSession(_endpoint,
                            new TimerItem(_timerWheel, new TimeSpan(0, 3, 0)), uri);
                        session.StateChanged += new EventHandler<OutboundCallSessionStateChangedEventArgs>(OnSessionStateChanged);
                        _sessions.Add(session);

                        session.Dial();
                    }

                    _startupWaitHandle.Set();
                }
                catch (RealTimeException ex)
                {
                    Console.WriteLine(ex);
                }
            },
            null);
        }

        void oninc(object sender, CallReceivedEventArgs<AudioVideoCall> e)
        {
            Console.WriteLine("call recvd");
        }

        void OnSessionStateChanged(object sender, OutboundCallSessionStateChangedEventArgs e)
        {
            // When a session finishes, increment the completed count.
            // When the completed count reaches the total number of sessions,
            // you are done.
            if (e.State == OutboundCallSessionState.Failed ||
                e.State == OutboundCallSessionState.Completed)
            {
                int newCompletedCount = Interlocked.Increment(ref _completedOrFailedSessions);

                if (newCompletedCount >= _sessions.Count)
                {
                    Console.WriteLine("All sessions have completed or failed.");
                    Console.WriteLine("Human answered: {0}", _sessions.Count(s => s.ResultType == ResultType.HumanAnswered));
                    Console.WriteLine("Voicemail answered: {0}", _sessions.Count(s => s.ResultType == ResultType.VoicemailAnswered));
                    Console.WriteLine("Exceeded max retries: {0}", _sessions.Count(s => s.ResultType == ResultType.ExceededRetryLimit));
                    Console.WriteLine("Failed: {0}", _sessions.Count(s => s.ResultType == ResultType.Failure));
                    Console.WriteLine("Press enter to shut down.");
                }
            }
        }

        internal void WaitForStartup()
        {
            _startupWaitHandle.WaitOne();
        }

        internal void Stop()
        {
            try
            {
                _endpoint.BeginTerminate(
                    terminateAsyncResult =>
                    {
                        try
                        {
                            _endpoint.EndTerminate(terminateAsyncResult);
                            Console.WriteLine("Terminated endpoint.");

                            ShutDownPlatform();
                        }
                        catch (RealTimeException ex)
                        {
                            Console.WriteLine(ex);
                        }
                    }, null);
            }
            catch (InvalidOperationException ex)
            {
                Console.WriteLine(ex);
            }
        }

        private void ShutDownPlatform()
        {
            try
            {
                _platform.BeginShutdown(shutdownAsyncResult =>
                {
                    try
                    {
                        _platform.EndShutdown(shutdownAsyncResult);
                        Console.WriteLine("Shut down platform.");
                    }
                    catch (RealTimeException ex)
                    {
                        Console.WriteLine(ex);
                    }
                },
                null);
            }
            catch (InvalidOperationException ex)
            {
                Console.WriteLine(ex);
            }
        }

       
    }
}

Code Listing 2: OutboundCallSession Class

OutboundCallSession Class

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.Rtc.Collaboration;
using Microsoft.Rtc.Collaboration.AudioVideo;
using Microsoft.Rtc.Signaling;
using System.Diagnostics;
using Microsoft.Speech.Synthesis;
using System.Threading;

namespace OutboundScheduler
{
    class OutboundCallSession
    {
        LocalEndpoint _endpoint;
        AudioVideoCall _call;
        string _uriToDial;
        string _id = Guid.NewGuid().ToString();

        Recorder _recorder;
        Stopwatch _stopwatch = new Stopwatch();

        ResultType _resultType = ResultType.None;

        OutboundCallSessionState _state = OutboundCallSessionState.Idle;

        TimerItem _timer;

        SpeechSynthesisConnector _connector = new SpeechSynthesisConnector();
        SpeechSynthesizer _synthesizer = new SpeechSynthesizer();

        int _retryCount = 0;

        public string Id
        {
            get { return _id; }
            set { _id = value; }
        }

        internal OutboundCallSessionState State
        {
            get { return _state; }
        }

        internal ResultType ResultType
        {
            get { return _resultType; }
            set { _resultType = value; }
        }

        internal event EventHandler<OutboundCallSessionStateChangedEventArgs> StateChanged;

        internal OutboundCallSession(LocalEndpoint endpoint, TimerItem timer, string uriToDial)
        {
            _endpoint = endpoint;
            _timer = timer;
            _uriToDial = uriToDial;
        }

        internal void Dial()
        {
            try
            {
                Conversation conv = new Conversation(_endpoint);
                _call = new AudioVideoCall(conv);
                Log("Calling...");
                HandleStateChange(OutboundCallSessionState.Dialing);

                // Establish the outgoing call.
                _call.BeginEstablish(_uriToDial, 
                    new CallEstablishOptions() { MaximumEstablishTime = new TimeSpan(0, 1, 0) },
                    ar =>
                    {
                        try
                        {
                            _call.EndEstablish(ar);

                            Log("Call established.");
                            HandleStateChange(OutboundCallSessionState.Answered);

                            // Start a recorder to track the voice
                            // activity on the other end of the call.
                            _recorder = new Recorder();
                            _recorder.VoiceActivityChanged += new EventHandler<VoiceActivityChangedEventArgs>(OnVoiceActivityChanged);
                            _recorder.AttachFlow(_call.Flow);
                            string tempFileName = string.Format("temp-{0}.wma", Guid.NewGuid());
                            WmaFileSink sink = new WmaFileSink(tempFileName);
                            _recorder.SetSink(sink);
                            _recorder.Start();

                            Log("Recorder attached");
                        }
                        catch (FailureResponseException frex)
                        {
                            Log("Failure response: {0}", frex.ResponseData.ResponseCode);

                            // If the failure code indicates a busy signal, retry.
                            if (frex.ResponseData.ResponseCode == ResponseCode.BusyHere ||
                                frex.ResponseData.ResponseCode == ResponseCode.BusyEverywhere)
                            {
                                Log("Busy...retrying in 3 minutes.");
                                HandleNoAnswer();
                            }
                            else
                            {
                                HandleFinalFailure(frex);
                            }
                        }
                        catch (OperationTimeoutException)
                        {
                            Log("No answer...retrying in 3 minutes.");
                            HandleNoAnswer();
                        }
                        catch (RealTimeException ex)
                        {
                            HandleFinalFailure(ex);
                        }
                    },
                    null);
            }
            catch (InvalidOperationException ex)
            {
                Log(ex);
            }
        }

        private void HandleFinalFailure(Exception ex)
        {
            Log(ex);
            HandleStateChange(OutboundCallSessionState.Failed);
            _resultType = ResultType.Failure;
        }

        private void HandleNoAnswer()
        {
            _call = null;

            int retryCount = Interlocked.Increment(ref _retryCount);

            // Retry up to three times.
            if (retryCount <= 3)
            {
                Log("Retry #{0} in 3 minutes...", retryCount);
                HandleStateChange(OutboundCallSessionState.WaitingForRetry);
                WaitForRetry();
            }
            else
            {
                Log("Exceeded maximum retry count.");
                _resultType = ResultType.ExceededRetryLimit;
                HandleStateChange(OutboundCallSessionState.Failed);
            }
        }

        private void WaitForRetry()
        {
            // Use the TimerItem to wait for 3 minutes.
            _timer.Expired += new EventHandler(OnRetryTimerExpired);
            _timer.Start();
        }

        void OnRetryTimerExpired(object sender, EventArgs e)
        {
            _timer.Expired -= OnRetryTimerExpired;
            Dial();
        }

        void OnVoiceActivityChanged(object sender, VoiceActivityChangedEventArgs e)
        {
            Log("Voice activity changed to {0}", e.IsVoice);
            if (e.IsVoice && !_stopwatch.IsRunning)
            {
                // Start the stopwatch when
                // there is voice activity.
                _stopwatch.Start();
            }
            if (!e.IsVoice && _stopwatch.IsRunning)
            {
                // When the voice activity stops, 
                // check how long it lasted.
                _recorder.VoiceActivityChanged -= OnVoiceActivityChanged;
                _stopwatch.Stop();         
                _recorder.Stop();
                _recorder.DetachFlow();
                _recorder = null;

                Log("Initial utterance was {0} milliseconds.", _stopwatch.ElapsedMilliseconds);

                // If the first utterance was longer than 4 seconds,
                // assume it is a voice mail message.
                if (_stopwatch.ElapsedMilliseconds > 4000)
                {
                    Log("voicemail");
                    _resultType = ResultType.VoicemailAnswered;

                    HandleVoicemailAnsweredCall();
                }
                else
                {
                    Log("human");
                    _resultType = ResultType.HumanAnswered;

                    HandleHumanAnsweredCall();
                }
            }
        }

        private void HandleVoicemailAnsweredCall()
        {
            Speak("You are a voicemail box.");
            Log("Speaking voicemail message...");
        }

        private void HandleHumanAnsweredCall()
        {
            Speak("You are a human.");
            Log("Speaking human message...");
        }

        private void Speak(string text)
        {
            // Attach the SpeechSynthesisConnector to the call.
            _connector.AttachFlow(_call.Flow);

            // Point the SpeechSynthesizer to the audio stream
            // exposed by the connector.
            _synthesizer.SetOutputToAudioStream(_connector.Stream, new Microsoft.Speech.AudioFormat.SpeechAudioFormatInfo(16000,
                Microsoft.Speech.AudioFormat.AudioBitsPerSample.Sixteen, Microsoft.Speech.AudioFormat.AudioChannel.Mono));

            // Start accepting speech synthesis for the call.
            _connector.Start();

            // Speak the text.
            _synthesizer.SpeakCompleted += new EventHandler<SpeakCompletedEventArgs>(OnSpeakCompleted);
            _synthesizer.SpeakAsync(text);
        }

        void OnSpeakCompleted(object sender, SpeakCompletedEventArgs e)
        {
            Log("Speech completed.");

            // Tear down the speech components.
            _connector.Stop();
            _synthesizer.SpeakCompleted -= OnSpeakCompleted;
            _synthesizer.Dispose();
            _connector.DetachFlow();
            _synthesizer = null;
            _connector = null;

            HandleStateChange(OutboundCallSessionState.Completed);
        }

        void Log(object obj)
        {
            StringBuilder builder = new StringBuilder();
            builder.AppendFormat("[URI {0}] - ",  _uriToDial);
            builder.Append(obj);
            Console.WriteLine(builder.ToString());
        }

        void Log(string str, params object[] items)
        {
            StringBuilder builder = new StringBuilder();
            builder.AppendFormat("[URI {0}] - ",  _uriToDial);
            builder.AppendFormat(str, items);
            Console.WriteLine(builder.ToString());
        }

        void HandleStateChange(OutboundCallSessionState newState)
        {
            OutboundCallSessionState oldState = _state;

            _state = newState;

            if (StateChanged != null)
            {
                StateChanged(this, 
                    new OutboundCallSessionStateChangedEventArgs()
                    {
                        PreviousState = oldState,
                        State = newState
                    }
                );
            }

            Log("State changed to {0}", newState);
        }
    }

    internal enum ResultType
    {
        None,
        HumanAnswered,
        VoicemailAnswered,
        ExceededRetryLimit,
        Failure
    }

    internal enum OutboundCallSessionState
    {
        Idle,
        Dialing,
        Answered,
        Completed,
        WaitingForRetry,
        Failed
    }

    internal class OutboundCallSessionStateChangedEventArgs : EventArgs
    {
        internal OutboundCallSessionState PreviousState { get; set; }
        internal OutboundCallSessionState State { get; set; }
    }
}

Conclusion

Outbound dialing functionality, although not included out of the box in Microsoft Lync Server 2010, can be built fairly easily using UCMA 3.0. This article has shown how to create a simple outgoing dialer application which applies some basic call progress analysis, call rescheduling, and reporting to help implement outgoing dialing campaigns.

Additional Resources

For more information, see the following resources:

About the Author

Michael Greenlee is currently a Senior Consultant at Avanade | LinkedIn.