diff --git a/LobbyCallSupportSample/LobbyCallSupportSample.sln b/LobbyCallSupportSample/LobbyCallSupportSample.sln new file mode 100644 index 00000000..3a659249 --- /dev/null +++ b/LobbyCallSupportSample/LobbyCallSupportSample.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.12.35728.132 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LobbyCallSupportSample", "LobbyCallSupportSample\LobbyCallSupportSample.csproj", "{3CAA0D48-2795-C343-DA4B-6B3F56CBB314}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {3CAA0D48-2795-C343-DA4B-6B3F56CBB314}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3CAA0D48-2795-C343-DA4B-6B3F56CBB314}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3CAA0D48-2795-C343-DA4B-6B3F56CBB314}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3CAA0D48-2795-C343-DA4B-6B3F56CBB314}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {99998281-4DF2-4536-B2AD-266FB84E2025} + EndGlobalSection +EndGlobal diff --git a/LobbyCallSupportSample/LobbyCallSupportSample/LobbyCallSupportSample.csproj b/LobbyCallSupportSample/LobbyCallSupportSample/LobbyCallSupportSample.csproj new file mode 100644 index 00000000..6b4baffb --- /dev/null +++ b/LobbyCallSupportSample/LobbyCallSupportSample/LobbyCallSupportSample.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + + + + + + + + diff --git a/LobbyCallSupportSample/LobbyCallSupportSample/Program.cs b/LobbyCallSupportSample/LobbyCallSupportSample/Program.cs new file mode 100644 index 00000000..76a8ccbd --- /dev/null +++ b/LobbyCallSupportSample/LobbyCallSupportSample/Program.cs @@ -0,0 +1,267 @@ +using Azure.Communication; +using Azure.Communication.CallAutomation; +using Azure.Core; +using Azure.Messaging; +using Azure.Messaging.EventGrid; +using Azure.Messaging.EventGrid.SystemEvents; +using System.Net.WebSockets; +using System.Text; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +// Configuration helper +string GetConfig(string key) => builder.Configuration[key] ?? throw new ArgumentNullException(paramName: key); + +string acsConnectionString = GetConfig(key: "acsConnectionString"), + cognitiveServiceEndpoint = GetConfig(key: "cognitiveServiceEndpoint"), + callbackUriHost = GetConfig(key: "callbackUriHost"), + acsLobbyCallReceiver = GetConfig(key: "acsLobbyCallReceiver"), + acsTargetCallReceiver = GetConfig(key: "acsTargetCallReceiver"); + +const string confirmationMessage = "A user is waiting in lobby, do you want to add the lobby user to your call?"; +const string lobbyMessage = "You are currently in a lobby call, we will notify the admin that you are waiting."; + +string acsUserId = string.Empty, + targetCallConnectionId = string.Empty, + lobbyCallConnectionId = string.Empty, + lobbyUserId = string.Empty; + +WebSocket? webSocket = null; +CallAutomationClient callAutomationClient = new(connectionString: acsConnectionString); + +// Event Handler +app.MapPost("/api/LobbyCallSupportEventHandler", async (EventGridEvent[] events, ILogger logger) => +{ + logger.LogInformation("~~~ /api/LobbyCallSupportEventHandler ~~~"); + try + { + foreach (var eventGridEvent in events) + { + if (!eventGridEvent.TryGetSystemEventData(out var eventData)) continue; + switch (eventData) + { + case SubscriptionValidationEventData validationEvent: + return Results.Ok(value: new SubscriptionValidationResponse { ValidationResponse = validationEvent.ValidationCode }); + + case AcsIncomingCallEventData incomingCallEvent: + logger.LogInformation("Event: {Type}", eventGridEvent.EventType); + acsUserId = incomingCallEvent.FromCommunicationIdentifier.RawId; + var toIdentifier = incomingCallEvent.ToCommunicationIdentifier.RawId; + if (toIdentifier.Contains(acsLobbyCallReceiver) || toIdentifier.Contains(acsTargetCallReceiver)) + { + var callbackUri = new Uri(baseUri: new Uri(callbackUriHost), relativeUri: "/api/callbacks"); + var answerCallOptions = new AnswerCallOptions( + incomingCallContext: incomingCallEvent.IncomingCallContext, + callbackUri: callbackUri) + { + OperationContext = !toIdentifier.Contains(acsTargetCallReceiver) ? "LobbyCall" : "OtherCall", + CallIntelligenceOptions = new CallIntelligenceOptions + { + CognitiveServicesEndpoint = new Uri(uriString: cognitiveServiceEndpoint) + } + }; + AnswerCallResult answerResult = await callAutomationClient.AnswerCallAsync(options: answerCallOptions); + if (toIdentifier.Contains(acsTargetCallReceiver)) + { + targetCallConnectionId = answerResult.CallConnection.CallConnectionId; + logger.LogInformation( + "Target Call Answered. From: {From}, To: {To}, ConnId: {Conn}, CorrId: {Corr}", + acsUserId, toIdentifier, targetCallConnectionId, incomingCallEvent.CorrelationId); + } + else + { + lobbyCallConnectionId = answerResult.CallConnection.CallConnectionId; + logger.LogInformation( + "Lobby Call Answered. From: {From}, To: {To}, ConnId: {Conn}, CorrId: {Corr}", + acsUserId, toIdentifier, lobbyCallConnectionId, incomingCallEvent.CorrelationId); + } + } + break; + } + } + return Results.Ok(); + } + catch (Exception ex) + { + logger.LogError(exception: ex, message: "Error"); + throw; + } +}); + +// Callback Handler +app.MapPost("/api/callbacks", async (CloudEvent[] events, ILogger logger) => +{ + try + { + foreach (var cloudEvent in events) + { + var callEvent = CallAutomationEventParser.Parse(cloudEvent); + var callConnection = callAutomationClient.GetCallConnection(callConnectionId: callEvent.CallConnectionId); + logger.LogInformation("~~~ /api/callbacks ~~~\n Event: {callEvent}", callEvent); + switch (callEvent) + { + case CallConnected callConnected when (callConnected.OperationContext ?? "") == "LobbyCall": + logger.LogInformation("\nCallConnected: {ConnId}", callConnected.CallConnectionId); + CallConnectionProperties callProperties = callConnection.GetCallConnectionProperties(); + lobbyUserId = callProperties.Source.RawId; + lobbyCallConnectionId = callProperties.CallConnectionId; + logger.LogInformation("Lobby Caller: {Caller}, Conn: {Conn}", lobbyUserId, lobbyCallConnectionId); + var callMedia = callAutomationClient.GetCallConnection(callConnected.CallConnectionId).GetCallMedia(); + var textSource = new TextSource(text: lobbyMessage) { VoiceName = "en-US-NancyNeural" }; + await callMedia.PlayAsync( + options: new PlayOptions( + playSource: textSource, + playTo: [new CommunicationUserIdentifier(id: acsUserId)]) + { + OperationContext = "playToContext" + }); + break; + + case PlayCompleted: + logger.LogInformation("PlayCompleted event"); + if (webSocket is null || webSocket.State != WebSocketState.Open) + { + logger.LogError("WebSocket unavailable"); + return Results.NotFound(value: "Message not sent"); + } + await webSocket.SendAsync( + buffer: Encoding.UTF8.GetBytes(confirmationMessage), + messageType: WebSocketMessageType.Text, + endOfMessage: true, + cancellationToken: CancellationToken.None); + logger.LogInformation("Target notified: {Msg}", confirmationMessage); + return Results.Ok(value: $"Target notified: {confirmationMessage}"); + + case MoveParticipantSucceeded moveParticipantSucceeded: + logger.LogInformation("MoveParticipantSucceeded: {ConnId}", moveParticipantSucceeded.CallConnectionId); + var targetConnection = callAutomationClient.GetCallConnection(callConnectionId: moveParticipantSucceeded.CallConnectionId); + var participants = await targetConnection.GetParticipantsAsync(); + LogParticipants(participants: participants.Value, logger: logger); + break; + + case CallDisconnected callDisconnected: + logger.LogInformation("CallDisconnected: {ConnId}", callDisconnected.CallConnectionId); + break; + } + } + return Results.Ok(); + } + catch (Exception ex) + { + logger.LogError(exception: ex, message: "Error"); + throw; + } +}).Produces(statusCode: 200); + +// Get Participants +app.MapGet("/GetParticipants/{connId}", async (string connId, ILogger logger) => +{ + logger.LogInformation("~~~ /GetParticipants/{ConnId} ~~~", connId); + try + { + var callConnection = callAutomationClient.GetCallConnection(callConnectionId: connId); + var participants = await callConnection.GetParticipantsAsync(); + if (!participants.Value.Any()) + return Results.NotFound(value: new { Message = "No participants found.", CallConnectionId = connId }); + LogParticipants(participants: participants.Value, logger: logger); + return Results.Ok(); + } + catch (Exception ex) + { + logger.LogError("Error getting participants: {Msg}", ex.Message); + throw; + } +}).WithTags("Lobby Call Support APIs"); + +// WebSocket +app.UseWebSockets(); +app.Map("/ws", async context => +{ + var logger = context.RequestServices.GetRequiredService>(); + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = 400; + return; + } + webSocket = await context.WebSockets.AcceptWebSocketAsync(); + var buffer = new byte[4096]; + while (webSocket.State == WebSocketState.Open) + { + try + { + var receiveResult = await webSocket.ReceiveAsync( + buffer: new ArraySegment(buffer), + cancellationToken: CancellationToken.None); + var clientMessage = Encoding.UTF8.GetString(buffer, 0, receiveResult.Count); + logger.LogInformation("Client response: {Msg}", clientMessage); + + if (receiveResult.MessageType == WebSocketMessageType.Close) + { + await webSocket.CloseAsync( + closeStatus: WebSocketCloseStatus.NormalClosure, + statusDescription: "Closing", + cancellationToken: CancellationToken.None); + } + else if (clientMessage.Equals("yes", StringComparison.OrdinalIgnoreCase)) + { + logger.LogInformation("Move Participant..."); + try + { + var targetConnection = callAutomationClient.GetCallConnection(callConnectionId: targetCallConnectionId); + CommunicationIdentifier participant = lobbyUserId.StartsWith("+") + ? new PhoneNumberIdentifier(phoneNumber: lobbyUserId) + : new CommunicationUserIdentifier(id: lobbyUserId); + var moveResult = await targetConnection.MoveParticipantsAsync( + options: new MoveParticipantsOptions( + targetParticipants: [participant], + fromCall: lobbyCallConnectionId)); + var rawResponse = moveResult.GetRawResponse(); + if (rawResponse.Status is >= 200 and <= 299) + logger.LogInformation("Move Participant operation is initiated."); + else + throw new Exception(message: $"Move failed: {rawResponse.Status}"); + } + catch (Exception ex) + { + logger.LogError("Move error: {Msg}", ex.Message); + throw; + } + } + } + catch (Exception ex) + { + logger.LogError("WebSocket error: {Msg}", ex.Message); + } + } +}); + +app.Run(); + +// Helper: Log participants +static void LogParticipants(IEnumerable participants, ILogger logger) +{ + var participantInfo = participants.Select(participant => participant.Identifier switch + { + PhoneNumberIdentifier phone => $"Phone - RawId: {phone.RawId}, Phone: {phone.PhoneNumber}", + CommunicationUserIdentifier user => $"ACSUser - RawId: {user.Id}", + _ => $"{participant.Identifier.GetType().Name} - RawId: {participant.Identifier.RawId}" + }).ToList(); + + logger.LogInformation( + "Participants ({Count}):\n{List}", + participantInfo.Count, + string.Join("\n", participantInfo.Select((info, index) => $"{index + 1}. {info}"))); +} \ No newline at end of file diff --git a/LobbyCallSupportSample/LobbyCallSupportSample/Properties/launchSettings.json b/LobbyCallSupportSample/LobbyCallSupportSample/Properties/launchSettings.json new file mode 100644 index 00000000..7e551222 --- /dev/null +++ b/LobbyCallSupportSample/LobbyCallSupportSample/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7006;http://localhost:5143", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/LobbyCallSupportSample/LobbyCallSupportSample/Resources/Lobby_Call_Support_Scenario.jpg b/LobbyCallSupportSample/LobbyCallSupportSample/Resources/Lobby_Call_Support_Scenario.jpg new file mode 100644 index 00000000..0bfb895e Binary files /dev/null and b/LobbyCallSupportSample/LobbyCallSupportSample/Resources/Lobby_Call_Support_Scenario.jpg differ diff --git a/LobbyCallSupportSample/LobbyCallSupportSample/appsettings.Development.json b/LobbyCallSupportSample/LobbyCallSupportSample/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/LobbyCallSupportSample/LobbyCallSupportSample/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/LobbyCallSupportSample/LobbyCallSupportSample/appsettings.json b/LobbyCallSupportSample/LobbyCallSupportSample/appsettings.json new file mode 100644 index 00000000..9e03a0ed --- /dev/null +++ b/LobbyCallSupportSample/LobbyCallSupportSample/appsettings.json @@ -0,0 +1,14 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "acsConnectionString": "", + "cognitiveServiceEndpoint": "", + "callbackUriHost": "", + "acsLobbyCallReceiver": "", + "acsTargetCallReceiver": "" +} \ No newline at end of file diff --git a/LobbyCallSupportSample/LobbyCallSupportSample/readme.md b/LobbyCallSupportSample/LobbyCallSupportSample/readme.md new file mode 100644 index 00000000..8d1f5b45 --- /dev/null +++ b/LobbyCallSupportSample/LobbyCallSupportSample/readme.md @@ -0,0 +1,186 @@ +| page_type | languages | products | +| --------- | --------------------------------------- | --------------------------------------------------------------------------- | +| Sample |
DotNetC#
|
azureazure-communication-services
| + +# Call Automation – Lobby Call Support Sample + +This sample demonstrates how to use the Call Automation SDK to implement a Lobby Call scenario with Azure Communication Services. Users join a lobby call and remain on hold until a participant in the target call confirms their participation. Once approved, the Call Automation bot automatically connects the lobby user to the designated target call. + +--- + +## Table of Contents +- [Overview](#overview) +- [Design](#design) +- [Prerequisites](#prerequisites) +- [Getting Started](#getting-started) +- [Configuration](#configuration) +- [Running the App Locally](#running-the-app-locally) +- [Workflow](#workflow) +- [Troubleshooting](#troubleshooting) + +--- + +## Overview + +This project provides a sample implementation for lobby call handling using Azure Communication Services and the Call Automation SDK. + +--- + +## Design + +![Lobby Call Support](./Resources/Lobby_Call_Support_Scenario.jpg) + +--- + +## Prerequisites + +- **Azure Account:** An Azure account with an active subscription. + https://azure.microsoft.com/free/?WT.mc_id=A261C142F. +- **Communication Services Resource:** A deployed Communication Services resource. + https://docs.microsoft.com/azure/communication-services/quickstarts/create-communication-resource. +- **Phone Number:** A https://learn.microsoft.com/en-us/azure/communication-services/quickstarts/telephony/get-phone-number in your ACS resource that can make outbound calls. +- **Azure AI Multi-Service Resource:** + https://learn.microsoft.com/en-us/azure/cognitive-services/cognitive-services-apis-create-account. +- **Azure Dev Tunnel:** + https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/get-started. +- **Client Application:** + Navigate to `LobbyCallSupport-Client` folder in https://github.com/Azure-Samples/communication-services-javascript-quickstarts. + +--- + +## Getting Started + +### Clone the Source Code + +1. Open PowerShell, Windows Terminal, Command Prompt, or equivalent. +2. Navigate to your desired directory. +3. Clone the repository: + ```sh + git clone https://github.com/Azure-Samples/Communication-Services-dotnet-quickstarts.git + +4. Open `LobbyCallSupportSample.sln` in Visual Studio. + +### Restore .NET Packages + +In the LobbyCallSupportSample directory, run: +```sh +dotnet restore +``` +--- + +## Setup and Host Azure Dev Tunnel + +``` +# Install Dev Tunnel CLI +dotnet tool install -g Microsoft.DevTunnels.Client + +# Authenticate with Azure +devtunnel login + +# Create and start a tunnel +devtunnel host -p 7006 +``` + +--- +## Configuration + +Before running the application, configure the following settings in the `appSettings.json` file: + +| Setting | Description | Example Value | +|----------------------------|----------------------------------------------------------------------------------------------------------------|----------------------------------------------------| +| `acsConnectionString` | The connection string for your Azure Communication Services resource. Find this in the Azure Portal under your resource’s **Keys** section. | `"endpoint=https://.communication.azure.com/;accesskey="` | +| `cognitiveServiceEndpoint` | The endpoint for your Azure Cognitive Services resource. Used to play media to participants in the call. | `"https://"` | +| `callbackUriHost` | The base URL where your app will listen for incoming events from Azure Communication Services. For local development, use your Azure Dev Tunnel URL. | `"https://.devtunnels.ms"` | +| `acsLobbyCallReceiver` | ACS identity for the lobby call receiver. Generated using ACS SDK or Azure Portal. | `"8:acs:"` | +| `acsTargetCallReceiver` | ACS identity for the target call receiver. Generated using ACS SDK or Azure Portal. | `"8:acs:"` | +| `acsTargetCallSender` | ACS identity for the target call sender. Generated using ACS SDK or Azure Portal. | `"8:acs:"` | + +--- +### How to Obtain These Values + +- **acsConnectionString:** + 1. Go to the Azure Portal. + 2. Navigate to your Communication Services resource. + 3. Select **Keys & Connection String**. + 4. Copy the **Connection String** value. + +- **cognitiveServiceEndpoint:** + 1. Create an Azure AI Multi-Service resource. + 2. Copy the endpoint from the resource overview page. + +- **callbackUriHost:** + 1. Set up an Azure Dev Tunnel as described in the prerequisites. + 2. Use the public URL provided by the Dev Tunnel as your callback URI host. + +- **acsLobbyCallReceiver / acsTargetCallReceiver / acsTargetCallSender:** + 1. Use the ACS web client or SDK to generate user identities. + 2. Store the generated identity strings here. +#### Example `appSettings.json` + +```json +{ + "acsConnectionString": "endpoint=https://.communication.azure.com/;accesskey=", + "cognitiveServiceEndpoint": "https://", + "callbackUriHost": "https://.devtunnels.ms", + "acsLobbyCallReceiver": "8:acs:", + "acsTargetCallReceiver": "8:acs:" +} +``` +--- +## Running the App Locally + + +1. **Generate ACS identities** for lobby and target participants in **Azure Portal**. 3 users are needed: + - `acsLobbyCallReceiver` – Lobby call receiver. + - `acsTargetCallReceiver` – Target call receiver. + - `Sender` – Target call sender. +2. **Setup EventSubscription** for incoming calls: + - Set up a Web hook(`https:///api/LobbyCallSupportEventHandler`) for callback. + - Add Filter: + - Key: `data.to.rawid`, operator: `string contains`, value: `acsLobbyCallReceiver, acsTargetCallReceiver` +3. Use the **JS Client App**, Navigate to `LobbyCallSupport-Client` folder in https://github.com/Azure-Samples/communication-services-javascript-quickstarts. +4. Use the **WebSocket**, `wss:///ws` in client app for client-server communication. + + +--- +## Workflow + +- Start target call in client app `LobbyCallSupport-Client`: + - Input token for `Sender` behalf of Target call sender. + - Input user ID for `acsTargetCallReceiver` for Target call receiver. + - Click **Start Call**. +- Incoming call from target sender → server answers → expect `Call Connected` event. +- From a test app or ACS client, **Lobby user** calls `acsLobbyCallReceiver` → CA answers call and automated voice plays: `You are currently in a lobby call, we will notify the admin that you are waiting.` +- Target call receives notification (a confirm dialog): `A user is waiting in lobby, do you want to add them to your call?` +- If confirmed, **Lobby user must accept the call when prompted to move in call test app** → expect **MoveParticipantSucceeded** event → lobby user joins target call. +- **If user does not accept the move call prompt → lobby user remains in lobby call.** +- If Target user declined → lobby user will not be moved to target call. + +--- + +## API Testing with Swagger + +You can explore and test the available API endpoints using the built-in Swagger UI: + +- **Swagger URL:** + [https://localhost:7006/swagger/index.html](https://localhost:7006/swagger/index.html) + +> If running in a dev tunnel or cloud environment, replace `localhost:7006` with your tunnel's public URL (e.g., `https://.devtunnels.ms/swagger/index.html`). + +--- +## Troubleshooting + +### Common Issues +- **Invalid ACS Connection String:** + Verify `acsConnectionString` in `appSettings.json`. +- **Callback URL Not Reachable:** + Ensure Dev Tunnel is running and URL matches `callbackUriHost`. +- **Phone Number Issues:** + Confirm numbers are provisioned and in E.164 format. +- **Identity Errors:** + Regenerate ACS identities if invalid. + +**Still having trouble?** +- Review the official https://learn.microsoft.com/azure/communication-services/. +- Search for similar issues or ask questions on https://learn.microsoft.com/answers/topics/azure-communication-services.html. +- Contact your Azure administrator or support team if you suspect a permissions or resource issue. \ No newline at end of file