Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Inbound call with IVR menu: appointment booking #99

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.33516.290
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CallAutomation_AppointmentBooking", "CallAutomation_AppointmentBooking\CallAutomation_AppointmentBooking.csproj", "{E0A69614-B3D9-446A-B9BC-D7D21B4A2DF8}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{E0A69614-B3D9-446A-B9BC-D7D21B4A2DF8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E0A69614-B3D9-446A-B9BC-D7D21B4A2DF8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E0A69614-B3D9-446A-B9BC-D7D21B4A2DF8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E0A69614-B3D9-446A-B9BC-D7D21B4A2DF8}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {31E6D6F2-4041-4119-AFF5-F575C0D8C7F6}
EndGlobalSection
EndGlobal
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
namespace CallAutomation_AppointmentBooking
{
public class AppointmentBookingConfig
{
/// <summary>
/// public Callback URI that will be used to answer incoming call event,
/// or handle mid-call events, such as CallConnected.
/// See README file for details on how to setup tunnel on your localhost to handle this.
/// </summary>
public Uri CallbackUri { get; init; }

/// <summary>
/// DirectOffered phonenumber is can be aquired from Azure Communication Service portal.
/// In order to answer Incoming PSTN call or make an outbound call to PSTN number,
/// Call Automation needs Directly offered PSTN number to do these actions.
/// </summary>
public string DirectOfferedPhonenumber { get; init; }

/// <summary>
/// List of all prompts from this sample's business logic.
/// These recorded prompts must be uploaded to publicily available Uri endpoint.
/// See README for pre-generated samples that can be used for demo.
/// </summary>
public Prompts AllPrompts { get; init; }

public class Prompts
{
public Uri MainMenu { get; init; }

public Uri Retry { get; init; }

public Uri Choice1 { get; init; }

public Uri Choice2 { get; init; }

public Uri Choice3 { get; init; }

public Uri PlayRecordingStarted { get; init; }

public Uri Goodbye { get; init; }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>d7d2fc43-754d-4dba-87ed-832e765c7a4d</UserSecretsId>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Azure.Communication.CallAutomation" Version="1.0.0-alpha.20230516.1" />
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.16.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.3" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using Azure.Communication;
using Azure.Communication.CallAutomation;
using CallAutomation_AppointmentBooking.Interfaces;

namespace CallAutomation_AppointmentBooking
{
/// <summary>
/// Reusuable common calling actions for business needs
/// </summary>
public class CallingModules : ICallingModules
{
private readonly CallConnection _callConnection;
private readonly AppointmentBookingConfig _appointmentBookingConfig;

public CallingModules(
CallConnection callConnection,
AppointmentBookingConfig appointmentBookingConfig)
{
_callConnection = callConnection;
_appointmentBookingConfig = appointmentBookingConfig;
}

public async Task<string> RecognizeTonesAsync(
CommunicationIdentifier targetToRecognize,
int minDigitToCollect,
int maxDigitToCollect,
Uri askPrompt,
Uri retryPrompt)
{
for (int i = 0; i < 3; i++)
{
// prepare recognize tones
CallMediaRecognizeDtmfOptions callMediaRecognizeDtmfOptions = new CallMediaRecognizeDtmfOptions(targetToRecognize, maxDigitToCollect);
callMediaRecognizeDtmfOptions.Prompt = new FileSource(askPrompt);
callMediaRecognizeDtmfOptions.InterruptPrompt = true;
callMediaRecognizeDtmfOptions.InitialSilenceTimeout = TimeSpan.FromSeconds(10);
callMediaRecognizeDtmfOptions.InterToneTimeout = TimeSpan.FromSeconds(10);
callMediaRecognizeDtmfOptions.StopTones = new List<DtmfTone> { DtmfTone.Pound, DtmfTone.Asterisk };

// Send request to recognize tones
StartRecognizingCallMediaResult startRecognizingResult = await _callConnection.GetCallMedia().StartRecognizingAsync(callMediaRecognizeDtmfOptions);

// Wait for recognize related event...
StartRecognizingEventResult recognizeEventResult = await startRecognizingResult.WaitForEventProcessorAsync();

if (recognizeEventResult.IsSuccess)
{
// success recognition - return the tones detected.
RecognizeCompleted recognizeCompleted = recognizeEventResult.SuccessResult;
string dtmfTones = ((DtmfResult)recognizeCompleted.RecognizeResult).ConvertToString();

// check if it collected the minimum digit it collected
if (dtmfTones.Length >= minDigitToCollect)
{
return dtmfTones;
}
}
else
{
// failed recognition - likely timeout
_ = recognizeEventResult.FailureResult;
}

// play retry prompt and retry again
await PlayMessageThenWaitUntilItEndsAsync(retryPrompt);
}

throw new Exception("Retried 3 times, Failed to get tones.");
}


public async Task PlayMessageThenWaitUntilItEndsAsync(Uri playPrompt)
{
// Play failure prompt and retry.
FileSource fileSource = new FileSource(playPrompt);
PlayResult playResult = await _callConnection.GetCallMedia().PlayToAllAsync(fileSource);

// ... wait for play to complete, then return
await playResult.WaitForEventProcessorAsync();
}

public async Task TerminateCallAsync()
{
// Terminate the call
await _callConnection.HangUpAsync(true);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using Azure.Communication.CallAutomation;
using Azure.Messaging;
using Microsoft.AspNetCore.Mvc;

namespace CallAutomation_AppointmentBooking.Controllers
{
/// <summary>
/// This is controller where it will recieve interim events from Call automation service.
/// We are utilizing event processor, this will handle events and relay to our business logic.
/// </summary>
[Route("api/[controller]")]
[ApiController]
public class EventController : ControllerBase
{
private readonly ILogger<EventController> _logger;
private readonly CallAutomationEventProcessor _eventProcessor;

public EventController(
ILogger<EventController> logger,
CallAutomationClient callAutomationClient)
{
_logger = logger;
_eventProcessor = callAutomationClient.GetEventProcessor();
}

[HttpPost]
public IActionResult CallbackEvent([FromBody] CloudEvent[] cloudEvents)
{
// Prase incoming event into solid base class of CallAutomationEvent.
// This is useful when we want to access the properties of the event easily, such as CallConnectionId.
// We are using this parsed event to log CallconnectionId of the event here.
CallAutomationEventBase? parsedBaseEvent = CallAutomationEventParser.ParseMany(cloudEvents).FirstOrDefault();
_logger.LogInformation($"Event Recieved. CallConnectionId[{parsedBaseEvent?.CallConnectionId}], Type Name[{parsedBaseEvent?.GetType().Name}]");

// Utilizing evnetProcessor here to easily handle mid-call call automation events.
// process event into processor, so events could be handled in CallingModule.
_eventProcessor.ProcessEvents(cloudEvents);
return Ok();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
using Azure.Communication;
using Azure.Communication.CallAutomation;
using Azure.Messaging.EventGrid;
using Azure.Messaging.EventGrid.SystemEvents;
using CallAutomation_AppointmentBooking.Interfaces;
using Microsoft.AspNetCore.Mvc;

namespace CallAutomation_AppointmentBooking.Controllers
{
/// <summary>
/// This is the controller for recieving an inbound call.
/// See README files how to setup incoming call and its incoming call event
/// </summary>
[Route("api/[controller]")]
[ApiController]
public class IncomingCallController : ControllerBase
{
private readonly ILogger<IncomingCallController> _logger;
private readonly CallAutomationClient _callAutomationClient;
private readonly AppointmentBookingConfig _appointmentBookingConfig;
private readonly ITopLevelMenuService _topLevelMenuService;
private readonly IOngoingEventHandler _ongoingEventHandler;

public IncomingCallController(
ILogger<IncomingCallController> logger,
CallAutomationClient callAutomationClient,
AppointmentBookingConfig appointmentBookingConfig,
ITopLevelMenuService topLevelMenuService,
IOngoingEventHandler ongoingEventHandler)
{
_logger = logger;
_callAutomationClient = callAutomationClient;
_appointmentBookingConfig = appointmentBookingConfig;
_topLevelMenuService = topLevelMenuService;
_ongoingEventHandler = ongoingEventHandler;
}

[HttpPost]
public async Task<IActionResult> IncomingCall([FromBody] object request)
{
string callConnectionId = string.Empty;
try
{
// Parse incoming call event using eventgrid parser
var httpContent = new BinaryData(request.ToString());
EventGridEvent cloudEvent = EventGridEvent.ParseMany(httpContent).First();

if (cloudEvent.EventType == SystemEventNames.EventGridSubscriptionValidation)
{
// this section is for handling initial handshaking with Event webhook registration
var eventData = cloudEvent.Data.ToObjectFromJson<SubscriptionValidationEventData>();
var responseData = new SubscriptionValidationResponse
{
ValidationResponse = eventData.ValidationCode
};

if (responseData.ValidationResponse != null)
{
_logger.LogInformation($"Incoming EventGrid event: Handshake Successful.");
return Ok(responseData);
}
}
else if (cloudEvent.EventType == SystemEventNames.AcsIncomingCall)
{
// parse again the data into ACS incomingCall event
AcsIncomingCallEventData incomingCallEventData = cloudEvent.Data.ToObjectFromJson<AcsIncomingCallEventData>();

// Answer Incoming call with incoming call event data
// IncomingCallContext can be used to answer the call
AnswerCallResult answerCallResult = await _callAutomationClient.AnswerCallAsync(incomingCallEventData.IncomingCallContext, _appointmentBookingConfig.CallbackUri);
callConnectionId = answerCallResult.CallConnectionProperties.CallConnectionId;

_ = Task.Run(async () =>
{
// attaching ongoing event handler for specific events
// This is useful for handling unexpected events could happen anytime (such as participants leaves the call and cal is disconnected)
_ongoingEventHandler.AttachCountParticipantsInTheCall(callConnectionId);
_ongoingEventHandler.AttachDisconnectedWrapup(callConnectionId);

// Wait for call to be connected.
// Wait for 40 seconds before throwing timeout error.
var tokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(40));
AnswerCallEventResult eventResult = await answerCallResult.WaitForEventProcessorAsync(tokenSource.Token);

if (eventResult.IsSuccess)
{
// call connected returned! Call is now established.
// invoke top level menu now the call is connected;
await _topLevelMenuService.InvokeTopLevelMenu(
CommunicationIdentifier.FromRawId(incomingCallEventData.FromCommunicationIdentifier.RawId),
answerCallResult.CallConnection,
eventResult.SuccessResult.ServerCallId);
}
});
}
}
catch (Exception e)
{
// Exception! Failed to answer the call.
_logger.LogError($"Exception while answer the call. CallConnectionId[{callConnectionId}], Exception[{e}]");
}

return Ok();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Azure.Communication.CallAutomation;
using Azure.Communication;

namespace CallAutomation_AppointmentBooking.Interfaces
{
public interface ICallingModules
{
Task<string> RecognizeTonesAsync(CommunicationIdentifier targetToRecognize, int minDigitToCollect, int maxDigitToCollect, Uri askPrompt, Uri retryPrompt);

Task PlayMessageThenWaitUntilItEndsAsync(Uri playPrompt);

Task TerminateCallAsync();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Azure.Communication;
using Azure.Communication.CallAutomation;

namespace CallAutomation_AppointmentBooking.Interfaces
{
public interface IOngoingEventHandler
{
void AttachCountParticipantsInTheCall(string callConnectionId);

void AttachDisconnectedWrapup(string callConnectionId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using Azure.Communication;
using Azure.Communication.CallAutomation;

namespace CallAutomation_AppointmentBooking.Interfaces
{
public interface ITopLevelMenuService
{
Task InvokeTopLevelMenu(CommunicationIdentifier originalTarget, CallConnection callConnection, string serverCallId);
}
}
Loading