-
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathProgram.cs
More file actions
1230 lines (1068 loc) · 55.4 KB
/
Program.cs
File metadata and controls
1230 lines (1068 loc) · 55.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
using Azure.Communication.CallAutomation;
using Azure.Communication;
using Azure.Identity;
using Azure.Messaging;
using Azure.Messaging.EventGrid;
using Azure.Messaging.EventGrid.SystemEvents;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using System.Collections.Concurrent;
using System.Net.Http.Headers;
using System.Text.Json.Nodes;
using ACSforMCS;
using ACSforMCS.Configuration;
using ACSforMCS.Services;
using ACSforMCS.Middleware;
using ACSforMCS.HealthChecks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging.Console;
using Polly;
using Polly.Extensions.Http;
using Azure.Security.KeyVault.Secrets;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
#region Azure Key Vault Configuration
// Configure Azure Key Vault for secure configuration management
// This allows storing sensitive values like connection strings and secrets in Azure Key Vault
// instead of in configuration files or environment variables
string? keyVaultEndpoint = builder.Configuration["KeyVault:Endpoint"];
if (!string.IsNullOrEmpty(keyVaultEndpoint))
{
// Add Azure Key Vault as a configuration provider using Managed Identity or DefaultAzureCredential
// This automatically retrieves secrets from Key Vault and makes them available as configuration values
builder.Configuration.AddAzureKeyVault(
new Uri(keyVaultEndpoint),
new DefaultAzureCredential());
// Key Vault configuration logged after app is built
}
else
{
// Warning will be logged after app is built
}
#endregion
#region Configuration Setup
// Register configuration objects for dependency injection
// This binds JSON configuration sections to strongly-typed classes
builder.Services.Configure<AppSettings>(builder.Configuration);
builder.Services.Configure<VoiceOptions>(builder.Configuration.GetSection("Voice"));
builder.Services.Configure<CallerIdOptions>(builder.Configuration.GetSection("CallerId"));
// Add standard ASP.NET Core services
builder.Services.AddControllers(); // Enable MVC controllers for API endpoints
builder.Services.AddEndpointsApiExplorer(); // Enable API exploration for minimal APIs
// Add Swagger generation only in Development environment for security
if (builder.Environment.IsDevelopment())
{
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo
{
Title = "ACS for MCS API",
Version = "v1.0",
Description = @"
# Azure Communication Services for Microsoft Copilot Studio (Development)
A comprehensive telephony automation solution that integrates Azure Communication Services with Microsoft Copilot Studio.
## ⚠️ DEVELOPMENT ENVIRONMENT
This API documentation is only available in Development environment. Production deployments have Swagger disabled for security.
## Features
- **Incoming Call Handling**: Automatically answers phone calls and connects them to your Copilot Studio agent
- **Real-time Transcription**: Speech-to-text conversion using Azure Cognitive Services
- **Bot Integration**: Seamless communication with Microsoft Copilot Studio via DirectLine
- **Call Monitoring**: Comprehensive health checks and monitoring endpoints
- **Scalable Architecture**: Built for enterprise-grade telephony automation
## Security
- **Development & Production**: All monitoring endpoints require `X-API-Key` header authentication
- **Azure Key Vault**: Secure configuration management with environment-specific secrets
- **Managed Identity**: Secure Azure resource access
- **API Documentation**: Requires authentication even in Development environment
## Authentication Required
All secured endpoints require the `X-API-Key` header with the value from Azure Key Vault secret `HealthCheckApiKey`.
## Architecture
```
Phone Call → Azure Communication Services → Event Grid → ACS for MCS → DirectLine → Copilot Studio
```
",
Contact = new Microsoft.OpenApi.Models.OpenApiContact
{
Name = "ACS for MCS Project",
Url = new Uri("https://github.com/holgerimbery/ACSforMCS"),
Email = "holger@imbery.de"
},
License = new Microsoft.OpenApi.Models.OpenApiLicense
{
Name = "MIT License",
Url = new Uri("https://github.com/holgerimbery/ACSforMCS/blob/main/LICENSE.md")
}
});
// Configure API Key authentication for Swagger
c.AddSecurityDefinition("ApiKey", new Microsoft.OpenApi.Models.OpenApiSecurityScheme
{
Type = Microsoft.OpenApi.Models.SecuritySchemeType.ApiKey,
In = Microsoft.OpenApi.Models.ParameterLocation.Header,
Name = "X-API-Key",
Description = @"
**API Key Authentication Required**
All monitoring and health check endpoints require authentication via the `X-API-Key` header in both Development and Production environments.
**Usage:**
```
X-API-Key: your-health-check-api-key
```
**Note:** The API key is stored in Azure Key Vault as `HealthCheckApiKey` secret and is required for accessing this documentation.
",
Scheme = "ApiKeyScheme"
});
// Tag definitions for better organization
c.TagActionsBy(api => api.ActionDescriptor.RouteValues.ContainsKey("action")
? new[] { api.ActionDescriptor.RouteValues["action"] ?? "Default" }
: new[] { api.GroupName ?? "Default" });
// Custom schema IDs to avoid conflicts
c.CustomSchemaIds(type => type.FullName?.Replace("+", "."));
// Add server information for Development
c.AddServer(new Microsoft.OpenApi.Models.OpenApiServer
{
Url = "https://acsformcs.azurewebsites.net",
Description = "Azure Web App (Development Mode)"
});
c.AddServer(new Microsoft.OpenApi.Models.OpenApiServer
{
Url = "https://localhost:5252",
Description = "Local Development Server"
});
// Include XML comments if available
var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
if (File.Exists(xmlPath))
{
c.IncludeXmlComments(xmlPath);
}
// Add global security requirement for secured endpoints
c.AddSecurityRequirement(new Microsoft.OpenApi.Models.OpenApiSecurityRequirement
{
{
new Microsoft.OpenApi.Models.OpenApiSecurityScheme
{
Reference = new Microsoft.OpenApi.Models.OpenApiReference
{
Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme,
Id = "ApiKey"
}
},
Array.Empty<string>()
}
});
}); // Add Swagger/OpenAPI documentation generation for Development only
}
// Load and validate application settings
var appSettings = new AppSettings();
builder.Configuration.Bind(appSettings); // Bind configuration to the settings object
// Validate that critical configuration values are present
// These are required for the application to function properly
ArgumentNullException.ThrowIfNullOrEmpty(appSettings.AcsConnectionString, nameof(appSettings.AcsConnectionString));
ArgumentNullException.ThrowIfNullOrEmpty(appSettings.CognitiveServiceEndpoint, nameof(appSettings.CognitiveServiceEndpoint));
ArgumentNullException.ThrowIfNullOrEmpty(appSettings.DirectLineSecret, nameof(appSettings.DirectLineSecret));
#endregion
# region Base URI Configuration
// Handle base URI configuration with clear precedence and proper error handling
// This supports both local development (with VS tunnels) and production deployment scenarios
var baseUri = Environment.GetEnvironmentVariable("VS_TUNNEL_URL")?.TrimEnd('/');
if (string.IsNullOrEmpty(baseUri))
{
// Get environment-specific BaseUri from Key Vault (ONLY source for deployed environments)
// This ensures each environment has its own dedicated configuration
var environment = builder.Environment.EnvironmentName; // Gets "Development" or "Production"
var secretName = $"BaseUri-{environment}";
baseUri = await GetBaseUriFromKeyVaultAsync(builder.Configuration, secretName);
// No fallback to avoid configuration conflicts like DevTunnel URLs persisting in general secrets
// Each environment MUST have its own BaseUri-{Environment} secret in Key Vault
if (string.IsNullOrEmpty(baseUri))
{
throw new InvalidOperationException($"BaseUri configuration missing. Required Key Vault secret '{secretName}' not found or empty. " +
$"Environment: {environment}. Please ensure the secret exists and contains the correct URL for this environment.");
}
}
// Log the source and value for debugging (without exposing the full URL in logs)
var source = Environment.GetEnvironmentVariable("VS_TUNNEL_URL") != null ? "VS_TUNNEL_URL environment variable" : $"Key Vault secret 'BaseUri-{builder.Environment.EnvironmentName}'";
Console.WriteLine($"✅ BaseUri loaded from: {source}");
appSettings.BaseUri = baseUri;
// Update the AppSettings configuration in the DI container to include the BaseUri
// This ensures that injected AppSettings instances have the correct BaseUri value
builder.Services.Configure<AppSettings>(settings => settings.BaseUri = baseUri);
#endregion
// Load Health Check API Key from Key Vault for production security
string? healthCheckApiKey = null;
#region Dependency Registration
// Register Azure Communication Services client as a singleton
// This client is used for all call automation operations (answering, transferring, etc.)
builder.Services.AddSingleton(new CallAutomationClient(connectionString: appSettings.AcsConnectionString));
// Register thread-safe call context storage for managing active calls
// This dictionary maps correlation IDs to call contexts, enabling proper routing of events and messages
builder.Services.AddSingleton<ConcurrentDictionary<string, CallContext>>(new ConcurrentDictionary<string, CallContext>());
// Configure HTTP client for DirectLine API communication with retry policies
builder.Services.AddHttpClient("DirectLine", client => {
// Set the base address for all DirectLine API calls
client.BaseAddress = new Uri(Constants.DirectLineBaseUrl);
// Configure authentication using the DirectLine secret as a Bearer token
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", appSettings.DirectLineSecret.Trim());
// Add standard headers required by DirectLine API
client.DefaultRequestHeaders.Add("User-Agent", "ACSforMCS/1.0");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
// Set an optimized timeout for API calls - reduced for faster failure detection
client.Timeout = TimeSpan.FromSeconds(10);
// NEW: Configure connection pooling for better performance
client.DefaultRequestHeaders.ConnectionClose = false;
})
// NEW: Configure connection pooling and performance settings
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler()
{
MaxConnectionsPerServer = 10, // Limit concurrent connections per server
UseCookies = false, // Disable cookies for better performance in API scenarios
UseProxy = false // Skip proxy detection for better performance
})
// Enhanced Polly retry policy with better logging and jitter
.AddTransientHttpErrorPolicy(policy =>
policy.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: retryAttempt =>
{
// Add jitter to prevent thundering herd
var baseDelay = TimeSpan.FromSeconds(Math.Pow(2, retryAttempt));
var jitter = TimeSpan.FromMilliseconds(Random.Shared.Next(0, 1000));
return baseDelay.Add(jitter);
},
onRetry: (outcome, timespan, retryCount, context) =>
{
// Note: Retry logging will be improved to use proper ILogger in future update
// Note: Retry logging will be improved to use proper ILogger in future update
Console.WriteLine($"DirectLine retry attempt {retryCount} after {timespan.TotalMilliseconds}ms due to: {outcome.Exception?.Message ?? "HTTP error"}");
}));
// NEW: Add dedicated HTTP client for health checks
builder.Services.AddHttpClient("DirectLineHealth", client => {
client.BaseAddress = new Uri("https://europe.directline.botframework.com/");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
client.Timeout = TimeSpan.FromSeconds(10); // Shorter timeout for health checks
client.DefaultRequestHeaders.ConnectionClose = false;
})
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler()
{
MaxConnectionsPerServer = 5,
UseCookies = false,
UseProxy = false
});
// Register the main call automation service that orchestrates bot communication
builder.Services.AddSingleton<CallAutomationService>();
// Add memory cache service for SSML template caching and performance optimization
builder.Services.AddMemoryCache();
#endregion
#region Health Checks
// Add health check monitoring only in Development for debugging
// Production uses external monitoring tools (Application Insights, Azure Monitor) for better performance
if (builder.Environment.IsDevelopment())
{
builder.Services.AddHealthChecks()
.AddCheck<DirectLineHealthCheck>("directline_api", tags: new[] { "ready" });
}
#endregion
#region Logging Configuration
// Configure logging based on environment for optimal performance
if (builder.Environment.IsDevelopment())
{
// Development: Detailed logging for debugging
builder.Logging.AddFilter("System.Net.Http.HttpClient", LogLevel.Information);
builder.Logging.AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Information);
builder.Logging.AddFilter("System.Net.Http.HttpClient.DirectLine.LogicalHandler", LogLevel.Information);
// Reduce WebSocket message processing noise
builder.Logging.AddFilter("ACSforMCS.Services.CallAutomationService", LogLevel.Information);
builder.Logging.AddFilter("ACSforMCS.Middleware.WebSocketMiddleware", LogLevel.Information);
}
else
{
// Production: Minimal logging for maximum performance
// Only log warnings and errors to reduce I/O overhead and improve performance
builder.Logging.AddFilter("System.Net.Http.HttpClient", LogLevel.Error);
builder.Logging.AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Error);
builder.Logging.AddFilter("System.Net.Http.HttpClient.DirectLine.LogicalHandler", LogLevel.Error);
builder.Logging.AddFilter("ACSforMCS.Services.CallAutomationService", LogLevel.Warning);
builder.Logging.AddFilter("ACSforMCS.Middleware.WebSocketMiddleware", LogLevel.Warning);
// Set global minimum log level for production performance
builder.Logging.SetMinimumLevel(LogLevel.Warning);
}
#endregion
var app = builder.Build();
// Load Health Check API Key only for Development environment
// Production has monitoring disabled for maximum performance
if (app.Environment.IsDevelopment())
{
healthCheckApiKey = await GetSecretFromKeyVaultAsync(builder.Configuration, "HealthCheckApiKey");
if (string.IsNullOrEmpty(healthCheckApiKey))
{
Console.WriteLine($"Warning: HealthCheckApiKey not found in Key Vault for Development environment - health endpoints will be disabled");
}
}
else
{
// Production: No health check API key needed since monitoring is disabled
Console.WriteLine("Production mode: Health monitoring disabled for optimal performance");
}
#region Service Resolution
// Resolve services for direct access in API endpoints
// These are used throughout the endpoint handlers for call processing
var callAutomationService = app.Services.GetRequiredService<CallAutomationService>();
var callStore = app.Services.GetRequiredService<ConcurrentDictionary<string, CallContext>>();
var client = app.Services.GetRequiredService<CallAutomationClient>();
var logger = app.Services.GetRequiredService<ILogger<Program>>();
#endregion
#region API Endpoints
/// <summary>
/// Basic health check endpoint that confirms the service is running.
/// Returns a simple greeting message to verify the service is operational.
/// </summary>
/// <returns>A greeting message indicating the service is running</returns>
/// <response code="200">Service is running normally</response>
app.MapGet("/", () => "Hello Azure Communication Services, here is Copilot Studio!")
.WithName("GetServiceStatus")
.WithTags("Service")
.WithSummary("Service Status Check")
.WithDescription("Returns a greeting message to confirm the service is running")
.Produces<string>(StatusCodes.Status200OK);
/// <summary>
/// Webhook endpoint for handling incoming call events from Azure Communication Services.
/// This endpoint receives EventGrid events when new calls arrive and initiates the call handling process.
/// </summary>
/// <param name="eventGridEvents">Array of EventGrid events containing call information</param>
/// <param name="logger">Logger instance for request logging</param>
/// <returns>200 OK with subscription validation response if needed</returns>
/// <response code="200">Event processed successfully</response>
/// <response code="400">Invalid event data</response>
app.MapPost("/api/incomingCall", async (
[FromBody] EventGridEvent[] eventGridEvents,
ILogger<Program> logger) =>
{
logger.LogInformation("IncomingCall webhook received with {EventCount} events", eventGridEvents.Length);
foreach (var eventGridEvent in eventGridEvents)
{
logger.LogInformation("Processing event: Type={EventType}, Subject={Subject}",
eventGridEvent.EventType, eventGridEvent.Subject);
logger.LogInformation("Incoming Call event received : {EventGridEvent}", JsonConvert.SerializeObject(eventGridEvent));
// Handle EventGrid system events (like subscription validation)
if (eventGridEvent.TryGetSystemEventData(out object eventData))
{
logger.LogInformation("System event detected: {EventDataType}", eventData.GetType().Name);
// Handle the subscription validation event required by EventGrid
// This is sent when setting up the webhook subscription
if (eventData is SubscriptionValidationEventData subscriptionValidationEventData)
{
logger.LogInformation("Subscription validation event - returning validation code");
var responseData = new SubscriptionValidationResponse
{
ValidationResponse = subscriptionValidationEventData.ValidationCode
};
return Results.Ok(responseData);
}
}
try
{
logger.LogInformation("Parsing incoming call event data");
// Parse the incoming call event data
var jsonNode = JsonNode.Parse(eventGridEvent.Data);
if (jsonNode == null)
{
logger.LogError("Failed to parse event data as JSON");
continue;
}
var jsonObject = jsonNode.AsObject();
var incomingCallContext = (string?)jsonObject["incomingCallContext"];
if (string.IsNullOrEmpty(incomingCallContext))
{
logger.LogError("Missing incomingCallContext in event data");
continue;
}
logger.LogInformation("Processing call with context: {IncomingCallContext}", incomingCallContext);
// Extract caller and callee information from the event data with configuration-driven behavior
var callerIdOptions = app.Services.GetRequiredService<IOptions<CallerIdOptions>>().Value;
var tempCorrelationId = Guid.NewGuid().ToString("N")[^8..]; // Temporary ID for extraction
var callerInfo = callerIdOptions.EnableCallerIdProcessing
? CallerInfoExtractor.ExtractCallerInfo(eventGridEvent, tempCorrelationId, logger)
: CallerInfoExtractor.CallerInfo.CreateDefault(tempCorrelationId);
if (callerIdOptions.EnableDetailedLogging)
{
logger.LogInformation("Extracted caller information - Status: {Status}, CallerId: {CallerId}, CalleeId: {CalleeId}, Quality: {Quality}",
callerInfo.Status, callerInfo.CallerId, callerInfo.CalleeId, callerInfo.DataQuality);
}
// Generate a unique callback URI for this call's events
var callbackUri = callAutomationService.GetCallbackUri();
logger.LogInformation("Generated callback URI: {CallbackUri}", callbackUri);
// Load voice options for optimized speech recognition
var voiceOptions = app.Services.GetRequiredService<IOptions<VoiceOptions>>().Value;
// Configure call answering with AI capabilities and optimized speech recognition
var answerCallOptions = new AnswerCallOptions(incomingCallContext, callbackUri)
{
// Enable AI-powered call intelligence features
CallIntelligenceOptions = new CallIntelligenceOptions()
{
CognitiveServicesEndpoint = new Uri(appSettings.CognitiveServiceEndpoint)
},
// Configure real-time speech-to-text transcription with optimization
TranscriptionOptions = new TranscriptionOptions(voiceOptions.Language)
{
TransportUri = callAutomationService.GetTranscriptionTransportUri(),
TranscriptionTransport = StreamingTransport.Websocket, // Use WebSocket for real-time streaming
EnableIntermediateResults = voiceOptions.EnableFastMode, // Enable for faster response in fast mode
StartTranscription = true // Begin transcription immediately when call connects
}
};
logger.LogInformation("Answering call with ACS client");
// Answer the incoming call with the configured options
AnswerCallResult answerCallResult = await client.AnswerCallAsync(answerCallOptions);
logger.LogInformation("Call answered successfully");
// Store the call context for later reference
var correlationId = answerCallResult?.CallConnectionProperties.CorrelationId;
logger.LogInformation("Call answered - Correlation Id: {CorrelationId}", correlationId);
if (correlationId != null)
{
// Create and store call context for tracking this call's state with caller information
var callContext = new CallContext()
{
CorrelationId = correlationId
};
// Apply the extracted caller information to the call context
callerInfo.CorrelationId = correlationId; // Update with actual correlation ID
callerInfo.ApplyToCallContext(callContext);
// Store the call context
callStore[correlationId] = callContext;
// Log comprehensive call information
logger.LogInformation("Call context created - Correlation ID: {CorrelationId}, CallerId: {CallerId}, CalleeId: {CalleeId}, CallerName: {CallerName}, Active calls: {ActiveCalls}",
correlationId, callContext.CallerId, callContext.CalleeId, callContext.CallerDisplayName, callStore.Count);
// Log data quality metrics for monitoring
logger.LogInformation("Call data quality: {Quality}, HasCallerInfo: {HasCallerInfo}, HasCalleeInfo: {HasCalleeInfo}, CallerType: {CallerType}",
callContext.GetDataQuality(), callContext.HasCallerInfo, callContext.HasCalleeInfo, callContext.CallerType);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Answer call exception - Message: {ErrorMessage}, StackTrace: {StackTrace}",
ex.Message, ex.StackTrace);
}
}
logger.LogInformation("IncomingCall webhook processing completed");
return Results.Ok();
})
.WithName("HandleIncomingCall")
.WithTags("Call Automation")
.WithSummary("Handle Incoming Call Events")
.WithDescription("Webhook endpoint for Azure Communication Services EventGrid events. Handles incoming call notifications and initiates bot conversations.")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest);
/// <summary>
/// Webhook endpoint for handling call automation events from Azure Communication Services.
/// This endpoint receives events throughout the call lifecycle (connected, disconnected, transfer events, etc.)
/// and coordinates the bot conversation flow.
/// </summary>
app.MapPost("/api/calls/{contextId}", async (
[FromBody] CloudEvent[] cloudEvents,
[FromRoute] string contextId,
ILogger<Program> logger) =>
{
foreach (var cloudEvent in cloudEvents)
{
// Parse the call automation event
CallAutomationEventBase @event = CallAutomationEventParser.Parse(cloudEvent);
logger.LogInformation("Event received: {CloudEvent}", JsonConvert.SerializeObject(@event));
// Get call connection and media objects for this event
var callConnection = client.GetCallConnection(@event.CallConnectionId);
var callMedia = callConnection?.GetCallMedia();
var correlationId = @event.CorrelationId;
if (callConnection == null || callMedia == null)
{
return Results.BadRequest($"Call objects failed to get for connection id {@event.CallConnectionId}.");
}
// Handle call connected event - this is when we start the bot conversation
if (@event is CallConnected callConnected)
{
try
{
// Log concurrent call monitoring information
logger.LogInformation("Call connected - Active calls: {ActiveCallCount}, New call correlation ID: {CorrelationId}",
callStore.Count, correlationId);
Conversation? conversation = null;
try
{
logger.LogInformation("Starting DirectLine conversation for call {CorrelationId}", correlationId);
// Attempt to start a DirectLine conversation with the bot
conversation = await callAutomationService.StartConversationAsync();
logger.LogInformation("DirectLine conversation created successfully: ConversationId={ConversationId}, StreamUrl={HasStreamUrl}",
conversation?.ConversationId, !string.IsNullOrEmpty(conversation?.StreamUrl));
}
catch (HttpRequestException ex) when (ex.Message.Contains("403") || ex.Message.Contains("401"))
{
// If the primary method fails with authentication issues, try the token-based approach
logger.LogWarning("Regular StartConversationAsync failed with {StatusCode}, trying token method for call {CorrelationId}: {Error}",
ex.Message.Contains("403") ? "403" : "401", correlationId, ex.Message);
try
{
conversation = await callAutomationService.StartConversationWithTokenAsync();
logger.LogInformation("Token-based DirectLine conversation created: ConversationId={ConversationId}",
conversation?.ConversationId);
}
catch (Exception tokenEx)
{
logger.LogError(tokenEx, "Both DirectLine authentication methods failed for call {CorrelationId}", correlationId);
// Continue call without bot - play error message
try
{
await callConnection.GetCallMedia().PlayToAllAsync(
new PlayToAllOptions(new TextSource("I'm sorry, our bot service is temporarily unavailable. Please try again later."))
{ OperationContext = "error-fallback" });
}
catch (Exception playEx)
{
logger.LogError(playEx, "Failed to play error message for call {CorrelationId}", correlationId);
}
// Don't throw - keep call alive but without bot
conversation = null;
}
}
catch (Exception ex)
{
logger.LogError(ex, "DirectLine conversation creation failed for call {CorrelationId}: {Error}", correlationId, ex.Message);
// Continue call without bot - play error message
try
{
await callConnection.GetCallMedia().PlayToAllAsync(
new PlayToAllOptions(new TextSource("I'm sorry, our service is temporarily unavailable. Please try again later."))
{ OperationContext = "error-fallback" });
}
catch (Exception playEx)
{
logger.LogError(playEx, "Failed to play error message for call {CorrelationId}", correlationId);
}
// Don't throw - keep call alive but without bot
conversation = null;
}
// Only proceed with bot integration if conversation was successfully created
if (conversation != null && !string.IsNullOrEmpty(conversation.ConversationId))
{
// Associate the bot conversation with this call
var conversationId = conversation.ConversationId;
if (callStore.ContainsKey(correlationId))
{
callStore[correlationId].ConversationId = conversationId;
logger.LogInformation("Associated conversation {ConversationId} with call {CorrelationId}", conversationId, correlationId);
// Caller information is now sent as part of the initial message above
}
else
{
logger.LogWarning("Call {CorrelationId} not found in call store when associating conversation", correlationId);
}
// Start listening for bot responses asynchronously
var cts = new CancellationTokenSource();
callAutomationService.RegisterTokenSource(correlationId, cts);
logger.LogInformation("Registered token source for call {CorrelationId}, active token sources: {ActiveCount}",
correlationId, callAutomationService.GetActiveTokenSourceCount());
// Parallel optimization: Start WebSocket listener and send greeting simultaneously
Task webSocketTask = Task.CompletedTask;
// Validate that we have a WebSocket URL for real-time bot communication
if (string.IsNullOrEmpty(conversation.StreamUrl))
{
logger.LogError("StreamUrl is null or empty for call {CorrelationId}, cannot listen to bot", correlationId);
}
else
{
logger.LogInformation("Starting bot WebSocket listener for call {CorrelationId}, StreamUrl: {StreamUrl}",
correlationId, conversation.StreamUrl);
// Start the bot listener immediately (parallel with greeting)
webSocketTask = Task.Run(async () =>
{
try
{
await callAutomationService.ListenToBotWebSocketAsync(conversation.StreamUrl, callConnection, cts.Token);
}
catch (Exception ex)
{
logger.LogError(ex, "WebSocket listener failed for call {CorrelationId}: {Error}", correlationId, ex.Message);
}
});
}
// Send initial greeting message to the bot to trigger conversation flow
logger.LogInformation("Sending initial greeting to bot for call {CorrelationId}", correlationId);
Task greetingTask = Task.Run(async () =>
{
try
{
// Send caller information as the initial message for the bot to parse
var callContext = callStore[correlationId];
var callerMessage = $"CALLER_ID={callContext.CallerId}|CALLEE_ID={callContext.CalleeId}|CALLER_NAME={callContext.CallerDisplayName}";
await callAutomationService.SendMessageAsync(conversationId, callerMessage);
logger.LogInformation("Initial caller information sent successfully for call {CorrelationId}: {CallerInfo}", correlationId, callerMessage);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to send initial greeting for call {CorrelationId}: {Error}", correlationId, ex.Message);
}
});
// Wait briefly to ensure both operations are initiated (don't block the webhook response)
_ = Task.WhenAll(webSocketTask, greetingTask);
}
else
{
logger.LogWarning("Call {CorrelationId} proceeding without bot conversation - DirectLine connection failed", correlationId);
}
}
catch (Exception ex)
{
logger.LogError(ex, "CRITICAL: Error processing CallConnected event for call {CorrelationId}: {Message}", correlationId, ex.Message);
// Log additional debugging information
logger.LogError("CallConnected debugging info - Call store count: {CallStoreCount}, Active token sources: {TokenSources}",
callStore.Count, callAutomationService.GetActiveTokenSourceCount());
}
}
// Handle call transfer events for monitoring and logging
if (@event is CallTransferAccepted transferAccepted)
{
logger.LogInformation("Call transfer accepted: {OperationContext}", transferAccepted.OperationContext);
}
if (@event is CallTransferFailed transferFailed)
{
logger.LogError("Call transfer failed: {OperationContext}, Code: {ResultCode}",
transferFailed.OperationContext, transferFailed.ResultInformation?.Code);
}
// Handle audio playback events for monitoring TTS operations
if (@event is PlayFailed)
{
logger.LogInformation("Play Failed");
}
if (@event is PlayCompleted)
{
logger.LogInformation("Play Completed");
}
// Handle transcription lifecycle events
if (@event is TranscriptionStarted transcriptionStarted)
{
logger.LogInformation("Transcription started: {OperationContext}", transcriptionStarted.OperationContext);
}
if (@event is TranscriptionStopped transcriptionStopped)
{
logger.LogInformation("Transcription stopped: {OperationContext}", transcriptionStopped.OperationContext);
}
// Handle call disconnection - clean up resources
if (@event is CallDisconnected)
{
logger.LogInformation("Call Disconnected - Remaining active calls: {ActiveCallCount}, Disconnected call correlation ID: {CorrelationId}",
callStore.Count > 0 ? callStore.Count - 1 : 0, correlationId);
// Clean up call-specific resources and cancel ongoing operations
callAutomationService.CleanupCall(correlationId);
}
}
return Results.Ok();
}).Produces(StatusCodes.Status200OK);
#endregion
#region Middleware Configuration
// Enable WebSocket support for real-time audio streaming
app.UseWebSockets();
// Add custom middleware for handling WebSocket connections from ACS
app.UseCallWebSockets(); // Use our custom middleware for call-specific WebSocket handling
#endregion
#region Pipeline Configuration
// Configure the HTTP request pipeline based on environment
if (app.Environment.IsDevelopment())
{
// Enable Swagger UI for API documentation and testing in Development only
app.UseSwagger();
// Add security middleware for Swagger endpoints
app.UseWhen(context => context.Request.Path.StartsWithSegments("/swagger"), appBuilder =>
{
appBuilder.Use(async (context, next) =>
{
// Check for API key authentication for all Swagger access
if (string.IsNullOrEmpty(healthCheckApiKey) ||
!context.Request.Headers.ContainsKey("X-API-Key") ||
context.Request.Headers["X-API-Key"] != healthCheckApiKey)
{
context.Response.StatusCode = 401;
context.Response.ContentType = "text/html";
await context.Response.WriteAsync(@"
<!DOCTYPE html>
<html>
<head>
<title>Unauthorized - Swagger Access</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; background-color: #f5f5f5; }
.container { background-color: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); max-width: 600px; margin: 0 auto; }
.error { color: #d13438; font-size: 18px; margin-bottom: 20px; }
.code { background-color: #f8f9fa; padding: 10px; border-radius: 4px; font-family: monospace; }
.note { color: #666; font-size: 14px; margin-top: 20px; }
</style>
</head>
<body>
<div class='container'>
<h1>🔒 Swagger API Documentation - Authentication Required</h1>
<div class='error'>❌ Unauthorized Access</div>
<p>Access to API documentation requires authentication with the <strong>X-API-Key</strong> header.</p>
<p><strong>Example:</strong></p>
<div class='code'>curl -H ""X-API-Key: your-api-key"" https://acsformcs.azurewebsites.net/swagger</div>
<div class='note'>
<strong>Note:</strong> This is the Development environment. The API key is stored in Azure Key Vault as 'HealthCheckApiKey'.
<br>Production deployments have Swagger documentation completely disabled for security.
</div>
</div>
</body>
</html>");
return;
}
await next();
});
});
app.UseSwaggerUI(c =>
{
c.SwaggerEndpoint("/swagger/v1/swagger.json", "ACS for MCS API v1.0");
c.RoutePrefix = "swagger"; // Serve the Swagger UI at /swagger
c.DocumentTitle = "ACS for MCS API Documentation (Development)";
c.DisplayRequestDuration();
c.EnableTryItOutByDefault();
c.EnableFilter();
c.ShowExtensions();
c.EnableDeepLinking();
// Custom CSS for better styling
c.InjectStylesheet("/swagger-ui/custom.css");
// Add custom JavaScript for enhanced functionality
c.InjectJavascript("/swagger-ui/custom.js");
// Configure OAuth if needed (currently using API Key)
c.OAuthClientId("swagger-ui");
c.OAuthAppName("ACS for MCS Swagger UI");
// Show API key input prominently
c.DefaultModelsExpandDepth(1);
c.DefaultModelExpandDepth(1);
c.DocExpansion(Swashbuckle.AspNetCore.SwaggerUI.DocExpansion.List);
// Custom header for API documentation with security notice
c.HeadContent = @"
<style>
.swagger-ui .topbar { background-color: #0078d4; }
.swagger-ui .topbar .topbar-wrapper .link { color: white; }
.swagger-ui .info .title { color: #0078d4; }
.swagger-ui .info .description { color: #d13438; font-weight: bold; }
.security-notice { color: #d13438; font-weight: bold; background: #fff3cd; padding: 10px; border: 1px solid #ffeaa7; border-radius: 4px; margin: 10px 0; }
</style>";
});
}
// Production: Swagger UI is completely disabled for security
// Enable authorization middleware (though not heavily used in this application)
app.UseAuthorization();
// Map controller routes for any additional API controllers
app.MapControllers();
// Health check endpoints are configured below based on environment
// This avoids duplicate registrations that cause AmbiguousMatchException
// Add concurrent call monitoring endpoint - now secured in both environments
if (app.Environment.IsDevelopment())
{
// Development: Secured health endpoints with API key authentication (consistent with Production)
app.MapGet("/health/calls", async (HttpContext context, IServiceProvider serviceProvider) =>
{
// Check for API key authentication (same as Production)
if (string.IsNullOrEmpty(healthCheckApiKey) ||
!context.Request.Headers.ContainsKey("X-API-Key") ||
context.Request.Headers["X-API-Key"] != healthCheckApiKey)
{
context.Response.StatusCode = 401;
await context.Response.WriteAsync("Unauthorized");
return;
}
var callAutomationService = serviceProvider.GetRequiredService<CallAutomationService>();
var logger = serviceProvider.GetRequiredService<ILogger<Program>>();
try
{
var activeCalls = callAutomationService.GetActiveCallCount();
var callDetails = callAutomationService.GetCallStatistics();
logger.LogInformation("Authenticated health check requested - Active calls: {ActiveCalls}", activeCalls);
await context.Response.WriteAsJsonAsync(new
{
timestamp = DateTime.UtcNow,
activeCalls = activeCalls,
statistics = callDetails, // Full details available in Development for debugging
status = activeCalls < 50 ? "healthy" : "warning",
maxRecommendedCalls = 45,
environment = "Development"
});
}
catch (Exception ex)
{
logger.LogError(ex, "Error retrieving call statistics for health check");
context.Response.StatusCode = 500;
await context.Response.WriteAsync("Error retrieving call statistics");
}
})
.WithName("GetDevelopmentCallMonitoring")
.WithTags("Health Checks", "Monitoring")
.WithSummary("Concurrent Call Monitoring (Development)")
.WithDescription("Returns detailed information about active calls and concurrent call statistics. Requires X-API-Key header for authentication.")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status500InternalServerError);
// Development: Secured system metrics endpoint
app.MapGet("/health/metrics", async (HttpContext context, IServiceProvider serviceProvider) =>
{
// Check for API key authentication
if (string.IsNullOrEmpty(healthCheckApiKey) ||
!context.Request.Headers.ContainsKey("X-API-Key") ||
context.Request.Headers["X-API-Key"] != healthCheckApiKey)
{
context.Response.StatusCode = 401;
await context.Response.WriteAsync("Unauthorized");
return;
}
var logger = serviceProvider.GetRequiredService<ILogger<Program>>();
var callAutomationService = serviceProvider.GetRequiredService<CallAutomationService>();
try
{
var process = System.Diagnostics.Process.GetCurrentProcess();
var gc = GC.GetTotalMemory(false);
var activeCalls = callAutomationService.GetActiveCallCount();
logger.LogInformation("System metrics requested via authenticated endpoint");
await context.Response.WriteAsJsonAsync(new
{
timestamp = DateTime.UtcNow,
environment = "Development",
systemMetrics = new
{
processId = process.Id,
workingSet = process.WorkingSet64,
gcMemory = gc,
threadCount = process.Threads.Count,
startTime = process.StartTime,
uptime = DateTime.UtcNow - process.StartTime
},
applicationMetrics = new
{
activeCalls = activeCalls,
maxConcurrentCalls = 45,
callCapacityUsed = Math.Round((double)activeCalls / 45 * 100, 2)
},
status = new
{
overall = activeCalls < 40 ? "healthy" : activeCalls < 50 ? "warning" : "critical",
memoryPressure = gc > 100_000_000 ? "high" : "normal",
threadUtilization = process.Threads.Count > 50 ? "high" : "normal"
}
});
}
catch (Exception ex)
{
logger.LogError(ex, "Error retrieving system metrics");
context.Response.StatusCode = 500;
await context.Response.WriteAsync("Error retrieving system metrics");
}
})
.WithName("GetDevelopmentSystemMetrics")
.WithTags("Health Checks", "Monitoring")
.WithSummary("System Performance Metrics (Development)")
.WithDescription("Returns detailed system performance metrics including memory usage, CPU metrics, and application statistics. Requires X-API-Key header for authentication.")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status500InternalServerError);