diff --git a/Directory.Packages.props b/Directory.Packages.props index 93412d02..2eac0ef7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,6 +22,7 @@ + @@ -30,6 +31,8 @@ + + diff --git a/Elsa.Integrations.sln b/Elsa.Integrations.sln index 3acc9ed7..7cce4bba 100644 --- a/Elsa.Integrations.sln +++ b/Elsa.Integrations.sln @@ -28,6 +28,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{A99FA26E-2 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Integrations.Slack.Tests", "test\Elsa.Integrations.Slack.Tests\Elsa.Integrations.Slack.Tests.csproj", "{861E1230-F9CB-450C-845E-04DFDA259E26}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Integrations.Telnyx", "src\Elsa.Integrations.Telnyx\Elsa.Integrations.Telnyx.csproj", "{128B2FC3-81A7-4327-9665-9155B05F21DA}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -46,6 +48,10 @@ Global {861E1230-F9CB-450C-845E-04DFDA259E26}.Debug|Any CPU.Build.0 = Debug|Any CPU {861E1230-F9CB-450C-845E-04DFDA259E26}.Release|Any CPU.ActiveCfg = Release|Any CPU {861E1230-F9CB-450C-845E-04DFDA259E26}.Release|Any CPU.Build.0 = Release|Any CPU + {128B2FC3-81A7-4327-9665-9155B05F21DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {128B2FC3-81A7-4327-9665-9155B05F21DA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {128B2FC3-81A7-4327-9665-9155B05F21DA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {128B2FC3-81A7-4327-9665-9155B05F21DA}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -54,5 +60,6 @@ Global {A8666FC4-66E1-4766-A22B-C410D42D03DD} = {527248D6-B851-4C8D-8667-E2FB0A91DABF} {9732E404-11B5-48DB-B5D9-97997F018830} = {527248D6-B851-4C8D-8667-E2FB0A91DABF} {861E1230-F9CB-450C-845E-04DFDA259E26} = {A99FA26E-2098-403A-BD04-6BBCFBE3AC7D} + {128B2FC3-81A7-4327-9665-9155B05F21DA} = {527248D6-B851-4C8D-8667-E2FB0A91DABF} EndGlobalSection EndGlobal diff --git a/Elsa.Integrations.sln.DotSettings b/Elsa.Integrations.sln.DotSettings new file mode 100644 index 00000000..f72dc16e --- /dev/null +++ b/Elsa.Integrations.sln.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/AnswerCall.cs b/src/Elsa.Integrations.Telnyx/Activities/AnswerCall.cs new file mode 100644 index 00000000..d3efcdd2 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/AnswerCall.cs @@ -0,0 +1,30 @@ +using System.Runtime.CompilerServices; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +public class AnswerCall : AnswerCallBase +{ + /// + public AnswerCall([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + } + + /// + /// The activity to schedule when the call was successfully answered. + /// + [Port] public IActivity? Connected { get; set; } + + /// + /// The activity to schedule when the call was no longer active. + /// + [Port] public IActivity? Disconnected { get; set; } + + /// + protected override async ValueTask HandleConnectedAsync(ActivityExecutionContext context) => await context.ScheduleActivityAsync(Connected); + + /// + protected override async ValueTask HandleDisconnectedAsync(ActivityExecutionContext context) => await context.ScheduleActivityAsync(Disconnected); +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/AnswerCallBase.cs b/src/Elsa.Integrations.Telnyx/Activities/AnswerCallBase.cs new file mode 100644 index 00000000..ff11ed6c --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/AnswerCallBase.cs @@ -0,0 +1,71 @@ +using Elsa.Extensions; +using Elsa.Integrations.Telnyx.Bookmarks; +using Elsa.Integrations.Telnyx.Client.Models; +using Elsa.Integrations.Telnyx.Client.Services; +using Elsa.Integrations.Telnyx.Extensions; +using Elsa.Integrations.Telnyx.Payloads.Call; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using Refit; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +/// Answer an incoming call. You must issue this command before executing subsequent commands on an incoming call. +/// +[Activity(Constants.Namespace, "Answer an incoming call. You must issue this command before executing subsequent commands on an incoming call.", Kind = ActivityKind.Task)] +public abstract class AnswerCallBase : Activity +{ + /// + protected AnswerCallBase(string? source = null, int? line = null) : base(source, line) + { + } + + /// + /// The call control ID to answer. Leave blank when the workflow is driven by an incoming call and you wish to pick up that one. + /// + [Input(DisplayName = "Call Control ID", Description = "The call control ID of the call to answer.", Category = "Advanced")] + public Input CallControlId { get; set; } = null!; + + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var callControlId = CallControlId.Get(context); + + var request = new AnswerCallRequest { ClientState = context.CreateCorrelatingClientState(context.Id) }; + + var telnyxClient = context.GetRequiredService(); + + try + { + // Send a request to Telnyx to answer the call. + await telnyxClient.Calls.AnswerCallAsync(callControlId, request, context.CancellationToken); + + // Create a bookmark so we can resume the workflow when the call is answered. + context.CreateBookmark(new AnswerCallStimulus(callControlId), ResumeAsync, true); + } + catch (ApiException e) + { + if (!await e.CallIsNoLongerActiveAsync()) throw; + await HandleDisconnectedAsync(context); + } + } + + /// + /// Invoked when the call was successfully answered. + /// + protected abstract ValueTask HandleConnectedAsync(ActivityExecutionContext context); + + /// + /// Invoked when the call was no longer active. + /// + protected abstract ValueTask HandleDisconnectedAsync(ActivityExecutionContext context); + + private async ValueTask ResumeAsync(ActivityExecutionContext context) + { + var payload = context.GetWorkflowInput(); + context.Set(Result, payload); + await HandleConnectedAsync(context); + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/BridgeCalls.cs b/src/Elsa.Integrations.Telnyx/Activities/BridgeCalls.cs new file mode 100644 index 00000000..b48d7bed --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/BridgeCalls.cs @@ -0,0 +1,32 @@ +using System.Runtime.CompilerServices; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using JetBrains.Annotations; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +[PublicAPI] +public class BridgeCalls : BridgeCallsBase +{ + /// + public BridgeCalls([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + } + + /// + /// The to execute when the source leg call is no longer active. + /// + [Port] public IActivity? Disconnected { get; set; } + + /// + /// The to execute when the two calls are bridged. + /// + [Port] public IActivity? Bridged { get; set; } + + /// + protected override async ValueTask HandleDisconnectedAsync(ActivityExecutionContext context) => await context.ScheduleActivityAsync(Disconnected, OnCompleted); + + /// + protected override async ValueTask HandleBridgedAsync(ActivityExecutionContext context) => await context.ScheduleActivityAsync(Bridged, OnCompleted); +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/BridgeCallsBase.cs b/src/Elsa.Integrations.Telnyx/Activities/BridgeCallsBase.cs new file mode 100644 index 00000000..bd6ce83a --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/BridgeCallsBase.cs @@ -0,0 +1,99 @@ +using Elsa.Extensions; +using Elsa.Integrations.Telnyx.Bookmarks; +using Elsa.Integrations.Telnyx.Client.Models; +using Elsa.Integrations.Telnyx.Client.Services; +using Elsa.Integrations.Telnyx.Extensions; +using Elsa.Integrations.Telnyx.Models; +using Elsa.Integrations.Telnyx.Payloads.Call; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using JetBrains.Annotations; +using Refit; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +/// Bridge two calls. +/// +[Activity(Constants.Namespace, "Bridge two calls.", Kind = ActivityKind.Task)] +[PublicAPI] +public abstract class BridgeCallsBase : Activity +{ + /// + protected BridgeCallsBase(string? source = null, int? line = null) : base(source, line) + { + } + + /// + /// The source call control ID of one of the call to bridge with. Leave empty to use the ambient inbound call control Id, if there is one. + /// + [Input(DisplayName = "Call Control ID A", Description = "The source call control ID of one of the call to bridge with. Leave empty to use the ambient inbound call control Id, if there is one.")] + public Input CallControlIdA { get; set; } = null!; + + /// + /// The destination call control ID of the call you want to bridge with. + /// + [Input(DisplayName = "Call Control ID B", Description = "The destination call control ID of the call you want to bridge with.")] + public Input CallControlIdB { get; set; } = null!; + + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var callControlIdA = CallControlIdA.Get(context); + var callControlIdB = CallControlIdB.Get(context); + var request = new BridgeCallsRequest(callControlIdB, ClientState: context.CreateCorrelatingClientState(context.Id)); + var telnyxClient = context.GetRequiredService(); + + try + { + await telnyxClient.Calls.BridgeCallsAsync(callControlIdA, request, context.CancellationToken); + + var bookmarkA = new WebhookEventStimulus(WebhookEventTypes.CallBridged, callControlIdA); + var bookmarkB = new WebhookEventStimulus(WebhookEventTypes.CallBridged, callControlIdB); + context.CreateBookmarks([bookmarkA, bookmarkB], ResumeAsync, false); + } + catch (ApiException e) + { + if (!await e.CallIsNoLongerActiveAsync()) throw; + + await HandleDisconnectedAsync(context); + } + } + + /// + /// Called when the call is disconnected. + /// + protected abstract ValueTask HandleDisconnectedAsync(ActivityExecutionContext context); + + /// + /// Called when the call is bridged. + /// + /// + protected abstract ValueTask HandleBridgedAsync(ActivityExecutionContext context); + + /// + /// Called when the activity is completed. + /// + protected async ValueTask OnCompleted(ActivityCompletedContext context) => await context.TargetContext.CompleteActivityAsync(); + + private async ValueTask ResumeAsync(ActivityExecutionContext context) + { + var payload = context.GetWorkflowInput()!; + var callControlIdA = CallControlIdA.Get(context); + var callControlIdB = CallControlIdB.Get(context); + ; + + if (payload.CallControlId == callControlIdA) context.SetProperty("CallBridgedPayloadA", payload); + if (payload.CallControlId == callControlIdB) context.SetProperty("CallBridgedPayloadB", payload); + + var callBridgedPayloadA = context.GetProperty("CallBridgedPayloadA"); + var callBridgedPayloadB = context.GetProperty("CallBridgedPayloadB"); + + if (callBridgedPayloadA != null && callBridgedPayloadB != null) + { + context.Set(Result, new(callBridgedPayloadA, callBridgedPayloadB)); + await HandleBridgedAsync(context); + } + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/CallAnswered.cs b/src/Elsa.Integrations.Telnyx/Activities/CallAnswered.cs new file mode 100644 index 00000000..3c460eee --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/CallAnswered.cs @@ -0,0 +1,54 @@ +using System.Runtime.CompilerServices; +using Elsa.Extensions; +using Elsa.Integrations.Telnyx.Bookmarks; +using Elsa.Integrations.Telnyx.Helpers; +using Elsa.Integrations.Telnyx.Payloads.Call; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using Elsa.Workflows.UIHints; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +/// Represents a Telnyx webhook event trigger. +/// +[Activity("Telnyx", "Telnyx", "A Telnyx webhook event that executes when a call is answered.", Kind = ActivityKind.Trigger)] +public class CallAnswered : Activity +{ + /// + public CallAnswered([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + } + + /// + /// A list of call control IDs to listen for. + /// + [Input(Description = "A list of call control IDs to listen for.", UIHint = InputUIHints.MultiText)] + public Input> CallControlIds { get; set; } = null!; + + /// + protected override void Execute(ActivityExecutionContext context) + { + var callControlIds = CallControlIds.Get(context); + + foreach (var callControlId in callControlIds) + { + var payload = new CallAnsweredStimulus(callControlId); + context.CreateBookmark(new() + { + Stimulus = payload, + Callback = Resume, + BookmarkName = Type, + IncludeActivityInstanceId = false + }); + } + } + + private async ValueTask Resume(ActivityExecutionContext context) + { + var input = context.GetWorkflowInput(WebhookSerializerOptions.Create()); + context.Set(Result, input); + await context.CompleteActivityAsync(); + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/CallHangup.cs b/src/Elsa.Integrations.Telnyx/Activities/CallHangup.cs new file mode 100644 index 00000000..259a3612 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/CallHangup.cs @@ -0,0 +1,54 @@ +using System.Runtime.CompilerServices; +using Elsa.Extensions; +using Elsa.Integrations.Telnyx.Bookmarks; +using Elsa.Integrations.Telnyx.Helpers; +using Elsa.Integrations.Telnyx.Payloads.Call; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using Elsa.Workflows.UIHints; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +/// Represents a Telnyx webhook event trigger. +/// +[Activity("Telnyx", "Telnyx", "A Telnyx webhook event that executes when a call is hangup.", Kind = ActivityKind.Trigger)] +public class CallHangup : Activity +{ + /// + public CallHangup([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + } + + /// + /// A list of call control IDs to listen for. + /// + [Input(Description = "A list of call control IDs to listen for.", UIHint = InputUIHints.MultiText)] + public Input> CallControlIds { get; set; } = null!; + + /// + protected override void Execute(ActivityExecutionContext context) + { + var callControlIds = CallControlIds.Get(context); + + foreach (var callControlId in callControlIds) + { + var payload = new CallHangupStimulus(callControlId); + context.CreateBookmark(new() + { + Stimulus = payload, + Callback = Resume, + BookmarkName = Type, + IncludeActivityInstanceId = false + }); + } + } + + private async ValueTask Resume(ActivityExecutionContext context) + { + var input = context.GetWorkflowInput(WebhookSerializerOptions.Create()); + context.Set(Result, input); + await context.CompleteActivityAsync(); + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/Dial.cs b/src/Elsa.Integrations.Telnyx/Activities/Dial.cs new file mode 100644 index 00000000..64326d22 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/Dial.cs @@ -0,0 +1,107 @@ +using System.Runtime.CompilerServices; +using Elsa.Extensions; +using Elsa.Integrations.Telnyx.Client.Models; +using Elsa.Integrations.Telnyx.Client.Services; +using Elsa.Integrations.Telnyx.Exceptions; +using Elsa.Integrations.Telnyx.Extensions; +using Elsa.Integrations.Telnyx.Options; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using Elsa.Workflows.UIHints; +using Microsoft.Extensions.Options; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +/// Dial a number or SIP URI. +/// +[Activity(Constants.Namespace, "Dial a number or SIP URI.", Kind = ActivityKind.Task)] +public class Dial : CodeActivity +{ + /// + public Dial([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + } + + /// + /// The DID or SIP URI to dial out and bridge to the given call. + /// + [Input(Description = "The DID or SIP URI to dial out and bridge to the given call.")] + public Input To { get; set; } = null!; + + /// + /// The 'from' number to be used as the caller id presented to the destination ('To' number). The number should be in +E164 format. This attribute will default to the 'From' number of the original call if omitted. + /// + [Input(Description = "The 'from' number to be used as the caller id presented to the destination ('To' number). The number should be in +E164 format. This attribute will default to the 'From' number of the original call if omitted.")] + public Input From { get; set; } = null!; + + /// + /// The string to be used as the caller id name (SIP From Display Name) presented to the destination ('To' number). The string should have a maximum of 128 characters, containing only letters, numbers, spaces, and -_~!.+ special characters. If omitted, the display name will be the same as the number in the 'From' field. + /// + [Input(Description = + "The string to be used as the caller id name (SIP From Display Name) presented to the destination ('To' number). The string should have a maximum of 128 characters, containing only letters, numbers, spaces, and -_~!.+ special characters. If omitted, the display name will be the same as the number in the 'From' field.")] + public Input FromDisplayName { get; set; } = null!; + + /// + /// Enables answering machine detection. + /// + [Input( + Description = "Enables answering machine detection.", + UIHint = InputUIHints.DropDown, + Options = new[] { "disabled", "detect", "detect_beep", "detect_words", "greeting_end", "premium" }, + DefaultValue = "disabled")] + public Input AnsweringMachineDetection { get; set; } = new("disabled"); + + /// + /// Start recording automatically after an event. Disabled by default. + /// + [Input(Description = "Start recording automatically after an event. Disabled by default.")] + public Input Record { get; set; } = null!; + + /// + /// Defines the format of the recording ('wav' or 'mp3') when `record` is specified. + /// + [Input( + Description = "Defines the format of the recording ('wav' or 'mp3') when `record` is specified.", + UIHint = InputUIHints.DropDown, + Options = new[] { "wav", "mp3" }, + DefaultValue = "mp3" + )] + public Input RecordFormat { get; set; } = new("mp3"); + + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var response = await DialAsync(context); + Result.Set(context, response); + } + + private async Task DialAsync(ActivityExecutionContext context) + { + var telnyxOptions = context.GetRequiredService>().Value; + var callControlAppId = telnyxOptions.CallControlAppId; + + if (callControlAppId == null) + throw new MissingCallControlAppIdException("No Call Control ID configured"); + + var fromNumber = From.GetOrDefault(context); + var clientState = context.CreateCorrelatingClientState(); + + var request = new DialRequest( + callControlAppId, + To.Get(context), + fromNumber, + FromDisplayName.GetOrDefault(context).SanitizeCallerName(), + AnsweringMachineDetection.GetOrDefault(context), + Record: Record.GetOrDefault(context) ? "record-from-answer" : null, + RecordFormat: RecordFormat.GetOrDefault(context) ?? "mp3", + ClientState: clientState + ); + + var telnyxClient = context.GetRequiredService(); + var response = await telnyxClient.Calls.DialAsync(request, context.CancellationToken); + + return response.Data; + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/DialAndWait.cs b/src/Elsa.Integrations.Telnyx/Activities/DialAndWait.cs new file mode 100644 index 00000000..b186df3d --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/DialAndWait.cs @@ -0,0 +1,132 @@ +using System.Runtime.CompilerServices; +using Elsa.Extensions; +using Elsa.Integrations.Telnyx.Attributes; +using Elsa.Integrations.Telnyx.Bookmarks; +using Elsa.Integrations.Telnyx.Client.Models; +using Elsa.Integrations.Telnyx.Client.Services; +using Elsa.Integrations.Telnyx.Exceptions; +using Elsa.Integrations.Telnyx.Extensions; +using Elsa.Integrations.Telnyx.Options; +using Elsa.Integrations.Telnyx.Payloads.Abstractions; +using Elsa.Integrations.Telnyx.Payloads.Call; +using Elsa.Workflows; +using Elsa.Workflows.Activities.Flowchart.Attributes; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using Elsa.Workflows.UIHints; +using Microsoft.Extensions.Options; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +/// Dial a number or SIP URI. +/// +[Activity(Constants.Namespace, "Dial a number or SIP URI and wait for an event.", Kind = ActivityKind.Task)] +[FlowNode("Answered", "Hangup")] +[WebhookDriven(WebhookEventTypes.CallAnswered, WebhookEventTypes.CallHangup)] +public class DialAndWait : Activity +{ + /// + public DialAndWait([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + } + + /// + /// The DID or SIP URI to dial out and bridge to the given call. + /// + [Input(Description = "The DID or SIP URI to dial out and bridge to the given call.")] + public Input To { get; set; } = null!; + + /// + /// The 'from' number to be used as the caller id presented to the destination ('To' number). The number should be in +E164 format. This attribute will default to the 'From' number of the original call if omitted. + /// + [Input(Description = "The 'from' number to be used as the caller id presented to the destination ('To' number). The number should be in +E164 format. This attribute will default to the 'From' number of the original call if omitted.")] + public Input From { get; set; } = null!; + + /// + /// The string to be used as the caller id name (SIP From Display Name) presented to the destination ('To' number). The string should have a maximum of 128 characters, containing only letters, numbers, spaces, and -_~!.+ special characters. If omitted, the display name will be the same as the number in the 'From' field. + /// + [Input(Description = + "The string to be used as the caller id name (SIP From Display Name) presented to the destination ('To' number). The string should have a maximum of 128 characters, containing only letters, numbers, spaces, and -_~!.+ special characters. If omitted, the display name will be the same as the number in the 'From' field.")] + public Input FromDisplayName { get; set; } = null!; + + /// + /// Enables answering machine detection. + /// + [Input( + Description = "Enables answering machine detection.", + UIHint = InputUIHints.DropDown, + Options = new[] { "disabled", "detect", "detect_beep", "detect_words", "greeting_end", "premium" }, + DefaultValue = "disabled")] + public Input AnsweringMachineDetection { get; set; } = new("disabled"); + + /// + /// Start recording automatically after an event. Disabled by default. + /// + [Input(Description = "Start recording automatically after an event. Disabled by default.")] + public Input Record { get; set; } = null!; + + /// + /// Defines the format of the recording ('wav' or 'mp3') when `record` is specified. + /// + [Input( + Description = "Defines the format of the recording ('wav' or 'mp3') when `record` is specified.", + UIHint = InputUIHints.DropDown, + Options = new[] { "wav", "mp3" }, + DefaultValue = "mp3" + )] + public Input RecordFormat { get; set; } = new("mp3"); + + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var response = await DialAsync(context); + var answeredBookmark = new WebhookEventStimulus(WebhookEventTypes.CallAnswered, response.CallControlId); + var hangupBookmark = new WebhookEventStimulus(WebhookEventTypes.CallHangup, response.CallControlId); + + context.CreateBookmark(answeredBookmark, OnCallAnswered, false); + context.CreateBookmark(hangupBookmark, OnCallHangup, false); + } + + private async ValueTask OnCallAnswered(ActivityExecutionContext context) + { + var payload = context.GetWorkflowInput(); + Result.Set(context, payload); + await context.CompleteActivityWithOutcomesAsync("Answered"); + } + + private async ValueTask OnCallHangup(ActivityExecutionContext context) + { + var payload = context.GetWorkflowInput(); + Result.Set(context, payload); + await context.CompleteActivityWithOutcomesAsync("Hangup"); + } + + private async Task DialAsync(ActivityExecutionContext context) + { + var telnyxOptions = context.GetRequiredService>().Value; + var callControlAppId = telnyxOptions.CallControlAppId; + + if (callControlAppId == null) + throw new MissingCallControlAppIdException("No Call Control ID configured"); + + var fromNumber = From.GetOrDefault(context); + var clientState = context.CreateCorrelatingClientState(); + + var request = new DialRequest( + callControlAppId, + To.Get(context), + fromNumber, + FromDisplayName.GetOrDefault(context).SanitizeCallerName(), + AnsweringMachineDetection.GetOrDefault(context), + Record: Record.GetOrDefault(context) ? "record-from-answer" : null, + RecordFormat: RecordFormat.GetOrDefault(context) ?? "mp3", + ClientState: clientState + ); + + var telnyxClient = context.GetRequiredService(); + var response = await telnyxClient.Calls.DialAsync(request, context.CancellationToken); + + return response.Data; + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/FlowAnswerCall.cs b/src/Elsa.Integrations.Telnyx/Activities/FlowAnswerCall.cs new file mode 100644 index 00000000..05594bca --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/FlowAnswerCall.cs @@ -0,0 +1,22 @@ +using System.Runtime.CompilerServices; +using Elsa.Workflows; +using Elsa.Workflows.Activities.Flowchart.Attributes; +using Elsa.Workflows.Activities.Flowchart.Models; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +[FlowNode("Connected", "Disconnected")] +public class FlowAnswerCall : AnswerCallBase +{ + /// + public FlowAnswerCall([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + } + + /// + protected override async ValueTask HandleConnectedAsync(ActivityExecutionContext context) => await context.CompleteActivityAsync(new Outcomes("Connected")); + + /// + protected override async ValueTask HandleDisconnectedAsync(ActivityExecutionContext context) => await context.CompleteActivityAsync(new Outcomes("Disconnected")); +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/FlowBridgeCalls.cs b/src/Elsa.Integrations.Telnyx/Activities/FlowBridgeCalls.cs new file mode 100644 index 00000000..c61b2be7 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/FlowBridgeCalls.cs @@ -0,0 +1,21 @@ +using System.Runtime.CompilerServices; +using Elsa.Workflows; +using Elsa.Workflows.Activities.Flowchart.Attributes; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +[FlowNode("Bridged", "Disconnected")] +public class FlowBridgeCalls : BridgeCallsBase +{ + /// + public FlowBridgeCalls([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + } + + /// + protected override ValueTask HandleDisconnectedAsync(ActivityExecutionContext context) => context.CompleteActivityAsync("Disconnected"); + + /// + protected override ValueTask HandleBridgedAsync(ActivityExecutionContext context) => context.CompleteActivityAsync("Bridged"); +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/FlowHangupCall.cs b/src/Elsa.Integrations.Telnyx/Activities/FlowHangupCall.cs new file mode 100644 index 00000000..184e4df7 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/FlowHangupCall.cs @@ -0,0 +1,21 @@ +using System.Runtime.CompilerServices; +using Elsa.Workflows; +using Elsa.Workflows.Activities.Flowchart.Attributes; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +[FlowNode("Done", "Disconnected")] +public class FlowHangupCall : HangupCallBase +{ + /// + public FlowHangupCall([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + } + + /// + protected override ValueTask HandleDoneAsync(ActivityExecutionContext context) => context.CompleteActivityWithOutcomesAsync("Done"); + + /// + protected override ValueTask HandleDisconnectedAsync(ActivityExecutionContext context) => context.CompleteActivityWithOutcomesAsync("Disconnected"); +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/FlowPlayAudio.cs b/src/Elsa.Integrations.Telnyx/Activities/FlowPlayAudio.cs new file mode 100644 index 00000000..835746f2 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/FlowPlayAudio.cs @@ -0,0 +1,21 @@ +using System.Runtime.CompilerServices; +using Elsa.Workflows; +using Elsa.Workflows.Activities.Flowchart.Attributes; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +[FlowNode("Playback started", "Disconnected")] +public class FlowPlayAudio : PlayAudioBase +{ + /// + public FlowPlayAudio([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + } + + /// + protected override ValueTask HandlePlaybackStartedAsync(ActivityExecutionContext context) => context.CompleteActivityWithOutcomesAsync("Playback started"); + + /// + protected override ValueTask HandleDisconnectedAsync(ActivityExecutionContext context) => context.CompleteActivityWithOutcomesAsync("Disconnected"); +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/FlowSpeakText.cs b/src/Elsa.Integrations.Telnyx/Activities/FlowSpeakText.cs new file mode 100644 index 00000000..d324f307 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/FlowSpeakText.cs @@ -0,0 +1,21 @@ +using System.Runtime.CompilerServices; +using Elsa.Workflows; +using Elsa.Workflows.Activities.Flowchart.Attributes; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +[FlowNode("Done", "Finished speaking", "Disconnected")] +public class FlowSpeakText : SpeakTextBase +{ + /// + public FlowSpeakText([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + } + + /// + protected override async ValueTask HandleDisconnected(ActivityExecutionContext context) => await context.CompleteActivityWithOutcomesAsync("Disconnected", "Done"); + + /// + protected override async ValueTask HandleSpeakingHasFinished(ActivityExecutionContext context) => await context.CompleteActivityWithOutcomesAsync("Finished speaking", "Done"); +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/FlowStartRecording.cs b/src/Elsa.Integrations.Telnyx/Activities/FlowStartRecording.cs new file mode 100644 index 00000000..fa7a6119 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/FlowStartRecording.cs @@ -0,0 +1,21 @@ +using System.Runtime.CompilerServices; +using Elsa.Workflows; +using Elsa.Workflows.Activities.Flowchart.Attributes; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +[FlowNode("Recording finished", "Disconnected")] +public class FlowStartRecording : StartRecordingBase +{ + /// + public FlowStartRecording([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + } + + /// + protected override ValueTask HandleDisconnectedAsync(ActivityExecutionContext context) => context.CompleteActivityWithOutcomesAsync("Disconnected"); + + /// + protected override ValueTask HandleCallRecordingSavedAsync(ActivityExecutionContext context) => context.CompleteActivityWithOutcomesAsync("Recording finished"); +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/FlowStopAudioPlayback.cs b/src/Elsa.Integrations.Telnyx/Activities/FlowStopAudioPlayback.cs new file mode 100644 index 00000000..8c277f24 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/FlowStopAudioPlayback.cs @@ -0,0 +1,21 @@ +using System.Runtime.CompilerServices; +using Elsa.Workflows; +using Elsa.Workflows.Activities.Flowchart.Attributes; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +[FlowNode("Done", "Disconnected")] +public class FlowStopAudioPlayback : StopAudioPlaybackBase +{ + /// + public FlowStopAudioPlayback([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + } + + /// + protected override ValueTask HandleDoneAsync(ActivityExecutionContext context) => context.CompleteActivityWithOutcomesAsync("Done"); + + /// + protected override ValueTask HandleDisconnectedAsync(ActivityExecutionContext context) => context.CompleteActivityWithOutcomesAsync("Disconnected"); +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/GatherUsingAudio.cs b/src/Elsa.Integrations.Telnyx/Activities/GatherUsingAudio.cs new file mode 100644 index 00000000..df359707 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/GatherUsingAudio.cs @@ -0,0 +1,158 @@ +using System.Runtime.CompilerServices; +using Elsa.Extensions; +using Elsa.Integrations.Telnyx.Attributes; +using Elsa.Integrations.Telnyx.Bookmarks; +using Elsa.Integrations.Telnyx.Client.Models; +using Elsa.Integrations.Telnyx.Client.Services; +using Elsa.Integrations.Telnyx.Extensions; +using Elsa.Integrations.Telnyx.Payloads.Call; +using Elsa.Workflows; +using Elsa.Workflows.Activities.Flowchart.Attributes; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using Elsa.Workflows.Runtime; +using Refit; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +/// Play an audio file on the call until the required DTMF signals are gathered to build interactive menus. +/// +[Activity(Constants.Namespace, "Play an audio file on the call until the required DTMF signals are gathered to build interactive menus.", Kind = ActivityKind.Task)] +[FlowNode("Valid input", "Invalid input", "Disconnected")] +[WebhookDriven(WebhookEventTypes.CallGatherEnded)] +public class GatherUsingAudio : Activity, IBookmarksPersistedHandler +{ + /// + public GatherUsingAudio([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + } + + /// + /// The call control ID of the call from which to gather input. Leave empty to use the ambient call control ID, if there is any. + /// + [Input(DisplayName = "Call Control ID", Description = "The call control ID of the call from which to gather input. Leave empty to use the ambient call control ID, if there is any.", Category = "Advanced")] + public Input CallControlId { get; set; } = null!; + + /// + /// The URL of a file to be played back at the beginning of each prompt. The URL can point to either a WAV or MP3 file. + /// + [Input( + DisplayName = "Audio URL", + Description = "The URL of a file to be played back at the beginning of each prompt. The URL can point to either a WAV or MP3 file." + )] + public Input? AudioUrl { get; set; } = null!; + + /// + /// The number of milliseconds to wait for input between digits. + /// + [Input( + DisplayName = "Inter Digit Timeout", + Description = "The number of milliseconds to wait for input between digits.", + Category = "Advanced", + DefaultValue = 5000 + )] + public Input? InterDigitTimeoutMillis { get; set; } = new(5000); + + /// + /// The URL of a file to play when digits don't match the Valid Digits setting or the number of digits is not between Min and Max. The URL can point to either a WAV or MP3 file. + /// + [Input( + DisplayName = "Invalid Audio Url", + Description = "The URL of a file to play when digits don't match the Valid Digits setting or the number of digits is not between Min and Max. The URL can point to either a WAV or MP3 file." + )] + public Input? InvalidAudioUrl { get; set; } + + /// + /// A list of all digits accepted as valid. + /// + [Input( + Description = "A list of all digits accepted as valid.", + Category = "Advanced", + DefaultValue = "0123456789#*" + )] + public Input? ValidDigits { get; set; } = new("0123456789#*"); + + /// + /// The minimum number of digits to fetch. This parameter has a minimum value of 1. + /// + [Input(Description = "The minimum number of digits to fetch. This parameter has a minimum value of 1.", DefaultValue = 1)] + public Input? MinimumDigits { get; set; } = new(1); + + /// + /// The maximum number of digits to fetch. This parameter has a maximum value of 128. + /// + [Input(Description = "The maximum number of digits to fetch. This parameter has a maximum value of 128.", DefaultValue = 128)] + public Input? MaximumDigits { get; set; } = new(128); + + /// + /// The maximum number of times the file should be played if there is no input from the user on the call. + /// + [Input(Description = "The maximum number of times the file should be played if there is no input from the user on the call.", DefaultValue = 3)] + public Input? MaximumTries { get; set; } = new(3); + + /// + /// The digit used to terminate input if fewer than `maximum_digits` digits have been gathered. + /// + [Input(Description = "The digit used to terminate input if fewer than `maximum_digits` digits have been gathered.", DefaultValue = "#")] + public Input? TerminatingDigit { get; set; } = new("#"); + + /// + /// The number of milliseconds to wait for a DTMF response after file playback ends before a replaying the sound file. + /// + [Input( + DisplayName = "Timeout", + Description = "The number of milliseconds to wait for a DTMF response after file playback ends before a replaying the sound file.", + Category = "Advanced", + DefaultValue = 60000 + )] + public Input? TimeoutMillis { get; set; } = new(60000); + + /// + /// Calls out to Telnyx to actually begin gathering input. + /// + public async ValueTask BookmarksPersistedAsync(ActivityExecutionContext context) + { + var request = new GatherUsingAudioRequest( + AudioUrl.Get(context) ?? throw new("AudioUrl is required."), + context.CreateCorrelatingClientState(), + null, + InterDigitTimeoutMillis.Get(context), + InvalidAudioUrl.Get(context), + MaximumDigits.Get(context), + MaximumTries.Get(context), + MinimumDigits.Get(context), + TerminatingDigit.Get(context).EmptyToNull(), + TimeoutMillis.Get(context), + ValidDigits.Get(context).EmptyToNull() + ); + + var callControlId = CallControlId.Get(context); + var telnyxClient = context.GetRequiredService(); + + try + { + await telnyxClient.Calls.GatherUsingAudioAsync(callControlId, request, context.CancellationToken); + } + catch (ApiException e) + { + if (!await e.CallIsNoLongerActiveAsync()) throw; + await context.CompleteActivityWithOutcomesAsync("Disconnected"); + } + } + + /// + protected override void Execute(ActivityExecutionContext context) + { + var callControlId = CallControlId.Get(context); + context.CreateBookmark(new WebhookEventStimulus(WebhookEventTypes.CallGatherEnded, callControlId), ResumeAsync); + } + + private async ValueTask ResumeAsync(ActivityExecutionContext context) + { + var payload = context.GetWorkflowInput(); + var outcome = payload.Status == "valid" ? "Valid input" : "Invalid input"; + context.Set(Result, payload); + await context.CompleteActivityWithOutcomesAsync(outcome); + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/GatherUsingSpeak.cs b/src/Elsa.Integrations.Telnyx/Activities/GatherUsingSpeak.cs new file mode 100644 index 00000000..fd704105 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/GatherUsingSpeak.cs @@ -0,0 +1,190 @@ +using System.Runtime.CompilerServices; +using Elsa.Extensions; +using Elsa.Integrations.Telnyx.Attributes; +using Elsa.Integrations.Telnyx.Bookmarks; +using Elsa.Integrations.Telnyx.Client.Models; +using Elsa.Integrations.Telnyx.Client.Services; +using Elsa.Integrations.Telnyx.Extensions; +using Elsa.Integrations.Telnyx.Payloads.Call; +using Elsa.Workflows; +using Elsa.Workflows.Activities.Flowchart.Attributes; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using Elsa.Workflows.UIHints; +using Refit; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +/// Convert text to speech and play it on the call until the required DTMF signals are gathered to build interactive menus. +/// +[Activity(Constants.Namespace, "Convert text to speech and play it on the call until the required DTMF signals are gathered to build interactive menus.", Kind = ActivityKind.Task)] +[FlowNode("Valid input", "Invalid input", "Disconnected")] +[WebhookDriven(WebhookEventTypes.CallGatherEnded)] +public class GatherUsingSpeak : Activity +{ + /// + public GatherUsingSpeak([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + } + + /// + /// The call control ID of the call from which to gather input. Leave empty to use the ambient call control ID, if there is any. + /// + [Input(DisplayName = "Call Control ID", Description = "The call control ID of the call from which to gather input. Leave empty to use the ambient call control ID, if there is any.", Category = "Advanced")] + public Input CallControlId { get; set; } = null!; + + /// + /// The language you want spoken. + /// + [Input( + Description = "The language you want spoken.", + UIHint = InputUIHints.DropDown, + Options = new[] { "en-US", "en-AU", "nl-NL", "es-ES", "ru-RU" }, + DefaultValue = "en-US" + )] + public Input Language { get; set; } = new("en-US"); + + /// + /// The gender of the voice used to speak back the text. + /// + [Input( + Description = "The gender of the voice used to speak back the text.", + UIHint = InputUIHints.DropDown, + Options = new[] { "female", "male" }, + DefaultValue = "female" + )] + public Input Voice { get; set; } = new("female"); + + /// + /// The text or SSML to be converted into speech. There is a 5,000 character limit. + /// + [Input( + Description = "The text or SSML to be converted into speech. There is a 5,000 character limit.", + UIHint = InputUIHints.MultiLine + )] + public Input Payload { get; set; } = null!; + + /// + /// The type of the provided payload. The payload can either be plain text, or Speech Synthesis Markup Language (SSML). + /// + [Input( + Description = "The type of the provided payload. The payload can either be plain text, or Speech Synthesis Markup Language (SSML).", + UIHint = InputUIHints.DropDown, + Options = new[] { "", "text", "ssml" } + )] + public Input? PayloadType { get; set; } + + /// + /// "This parameter impacts speech quality, language options and payload types. When using `basic`, only the `en-US` language and payload type `text` are allowed." + /// + [Input( + Description = "This parameter impacts speech quality, language options and payload types. When using `basic`, only the `en-US` language and payload type `text` are allowed.", + UIHint = InputUIHints.DropDown, + Options = new[] { "", "basic", "premium" }, + Category = "Advanced" + )] + public Input? ServiceLevel { get; set; } + + /// + /// The number of milliseconds to wait for input between digits. + /// + [Input( + DisplayName = "Inter Digit Timeout", + Description = "The number of milliseconds to wait for input between digits.", + Category = "Advanced", + DefaultValue = 5000 + )] + public Input? InterDigitTimeoutMillis { get; set; } = new(5000); + + /// + /// A list of all digits accepted as valid. + /// + [Input( + Description = "A list of all digits accepted as valid.", + Category = "Advanced", + DefaultValue = "0123456789#*" + )] + public Input? ValidDigits { get; set; } = new("0123456789#*"); + + /// + /// The minimum number of digits to fetch. This parameter has a minimum value of 1. + /// + [Input(Description = "The minimum number of digits to fetch. This parameter has a minimum value of 1.", DefaultValue = 1)] + public Input? MinimumDigits { get; set; } = new(1); + + /// + /// The maximum number of digits to fetch. This parameter has a maximum value of 128. + /// + [Input(Description = "The maximum number of digits to fetch. This parameter has a maximum value of 128.", DefaultValue = 128)] + public Input? MaximumDigits { get; set; } = new(128); + + /// + /// The maximum number of times the file should be played if there is no input from the user on the call. + /// + [Input(Description = "The maximum number of times the file should be played if there is no input from the user on the call.", DefaultValue = 3)] + public Input? MaximumTries { get; set; } = new(3); + + /// + /// The digit used to terminate input if fewer than `maximum_digits` digits have been gathered. + /// + [Input(Description = "The digit used to terminate input if fewer than `maximum_digits` digits have been gathered.", DefaultValue = "#")] + public Input? TerminatingDigit { get; set; } = new("#"); + + /// + /// The number of milliseconds to wait for a DTMF response after file playback ends before a replaying the sound file. + /// + [Input( + DisplayName = "Timeout", + Description = "The number of milliseconds to wait for a DTMF response after file playback ends before a replaying the sound file.", + Category = "Advanced", + DefaultValue = 60000 + )] + public Input? TimeoutMillis { get; set; } = new(60000); + + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var callControlId = CallControlId.Get(context); + + var request = new GatherUsingSpeakRequest( + Language.Get(context) ?? throw new("Language is required."), + Voice.Get(context) ?? throw new("Voice is required."), + Payload.Get(context) ?? throw new("Payload is required."), + PayloadType.GetOrDefault(context), + ServiceLevel.GetOrDefault(context), + InterDigitTimeoutMillis.GetOrDefault(context), + MaximumDigits.GetOrDefault(context), + MaximumTries.GetOrDefault(context), + MinimumDigits.GetOrDefault(context), + TerminatingDigit.GetOrDefault(context).EmptyToNull(), + TimeoutMillis.GetOrDefault(context), + ValidDigits.GetOrDefault(context).EmptyToNull(), + context.CreateCorrelatingClientState(context.Id) + ); + + var telnyxClient = context.GetRequiredService(); + + try + { + // Send the request to Telnyx. + await telnyxClient.Calls.GatherUsingSpeakAsync(callControlId, request, context.CancellationToken); + + // Create a bookmark so we can resume this activity when the call.gather.ended webhook comes back. + context.CreateBookmark(new WebhookEventStimulus(WebhookEventTypes.CallGatherEnded, callControlId), ResumeAsync, true); + } + catch (ApiException e) + { + if (!await e.CallIsNoLongerActiveAsync()) throw; + await context.CompleteActivityWithOutcomesAsync("Disconnected"); + } + } + + private async ValueTask ResumeAsync(ActivityExecutionContext context) + { + var payload = context.GetWorkflowInput(); + var outcome = payload.Status == "valid" ? "Valid input" : "Invalid input"; + context.Set(Result, payload); + await context.CompleteActivityWithOutcomesAsync(outcome); + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/GetCallStatus.cs b/src/Elsa.Integrations.Telnyx/Activities/GetCallStatus.cs new file mode 100644 index 00000000..6dd96d9e --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/GetCallStatus.cs @@ -0,0 +1,44 @@ +using System.Runtime.CompilerServices; +using Elsa.Extensions; +using Elsa.Integrations.Telnyx.Client.Services; +using Elsa.Workflows; +using Elsa.Workflows.Activities.Flowchart.Attributes; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +[FlowNode("Alive", "Dead", "Done")] +[Activity(Constants.Namespace, "Get the status of a call.", Kind = ActivityKind.Task)] +public class GetCallStatus : Activity +{ + /// + public GetCallStatus([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + } + + /// + /// Unique identifier and token for controlling the call. + /// + [Input( + DisplayName = "Call Control ID", + Description = "Unique identifier and token for controlling the call.", + Category = "Advanced" + )] + public Input CallControlId { get; set; } = null!; + + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var client = context.GetRequiredService(); + var callControlId = CallControlId.Get(context); + var response = await client.Calls.GetStatusAsync(callControlId, context.CancellationToken); + var isAlive = response.Data.IsAlive; + var outcome = isAlive ? "Alive" : "Dead"; + + Result.Set(context, isAlive); + + await context.CompleteActivityWithOutcomesAsync(outcome, "Done"); + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/HangupCall.cs b/src/Elsa.Integrations.Telnyx/Activities/HangupCall.cs new file mode 100644 index 00000000..405a06eb --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/HangupCall.cs @@ -0,0 +1,32 @@ +using System.Runtime.CompilerServices; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using JetBrains.Annotations; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +[PublicAPI] +public class HangupCall : HangupCallBase +{ + /// + public HangupCall([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + } + + /// + /// The to execute when the call was no longer active. + /// + [Port] public IActivity? Disconnected { get; set; } + + /// + protected override async ValueTask HandleDoneAsync(ActivityExecutionContext context) => await context.CompleteActivityAsync(OnCompletedAsync); + + /// + protected override async ValueTask HandleDisconnectedAsync(ActivityExecutionContext context) => await context.ScheduleActivityAsync(Disconnected, OnCompletedAsync); + + /// + /// Executed when any child activity completed. + /// + private async ValueTask OnCompletedAsync(ActivityCompletedContext context) => await context.TargetContext.CompleteActivityAsync(); +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/HangupCallBase.cs b/src/Elsa.Integrations.Telnyx/Activities/HangupCallBase.cs new file mode 100644 index 00000000..ee803f85 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/HangupCallBase.cs @@ -0,0 +1,57 @@ +using Elsa.Extensions; +using Elsa.Integrations.Telnyx.Client.Models; +using Elsa.Integrations.Telnyx.Client.Services; +using Elsa.Integrations.Telnyx.Extensions; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using Refit; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +/// Hang up the call. +/// +[Activity(Constants.Namespace, "Hang up the call.", Kind = ActivityKind.Task)] +public abstract class HangupCallBase : Activity +{ + /// + protected HangupCallBase(string? source = null, int? line = null) : base(source, line) + { + } + + /// + /// Unique identifier and token for controlling the call. + /// + [Input(DisplayName = "Call Control ID", Description = "Unique identifier and token for controlling the call.", Category = "Advanced")] + public Input CallControlId { get; set; } = null!; + + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var callControlId = CallControlId.Get(context); + var request = new HangupCallRequest(ClientState: context.CreateCorrelatingClientState()); + var telnyxClient = context.GetRequiredService(); + + try + { + await telnyxClient.Calls.HangupCallAsync(callControlId, request, context.CancellationToken); + await HandleDoneAsync(context); + } + catch (ApiException e) + { + if (!await e.CallIsNoLongerActiveAsync()) throw; + await HandleDisconnectedAsync(context); + } + } + + /// + /// Executed when the call was hangup. + /// + protected abstract ValueTask HandleDoneAsync(ActivityExecutionContext context); + + /// + /// Executed when the call was no longer active. + /// + protected abstract ValueTask HandleDisconnectedAsync(ActivityExecutionContext context); +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/IncomingCall.cs b/src/Elsa.Integrations.Telnyx/Activities/IncomingCall.cs new file mode 100644 index 00000000..99895da9 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/IncomingCall.cs @@ -0,0 +1,88 @@ +using System.Runtime.CompilerServices; +using Elsa.Expressions.Models; +using Elsa.Extensions; +using Elsa.Integrations.Telnyx.Bookmarks; +using Elsa.Integrations.Telnyx.Helpers; +using Elsa.Integrations.Telnyx.Models; +using Elsa.Integrations.Telnyx.Payloads.Call; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using Elsa.Workflows.UIHints; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +/// Triggered when an inbound phone call is received for any of the specified source or destination phone numbers. +/// +[Activity( + "Telnyx", + "Telnyx", + "Triggered when an inbound phone call is received for any of the specified source or destination phone numbers.", + Kind = ActivityKind.Trigger)] +public class IncomingCall : Trigger +{ + /// + public IncomingCall([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + } + + /// + /// A list of destination numbers to respond to. + /// + [Input(Description = "A list of destination numbers to respond to.", UIHint = InputUIHints.MultiText)] + public Input> To { get; set; } = null!; + + /// + /// A list of source numbers to respond to. + /// + [Input(Description = "A list of source numbers to respond to.", UIHint = InputUIHints.MultiText)] + public Input> From { get; set; } = null!; + + /// + /// Match any inbound calls. + /// + [Input(Description = "Match any inbound calls.")] + public Input CatchAll { get; set; } = null!; + + /// + protected override IEnumerable GetTriggerPayloads(TriggerIndexingContext context) => GetBookmarkPayloads(context.ExpressionExecutionContext); + + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + if (context.IsTriggerOfWorkflow()) + { + await ResumeAsync(context); + } + else + { + var bookmarkPayloads = GetBookmarkPayloads(context.ExpressionExecutionContext); + foreach (var bookmarkPayload in bookmarkPayloads) context.CreateBookmark(bookmarkPayload, ResumeAsync); + } + } + + private async ValueTask ResumeAsync(ActivityExecutionContext context) + { + var webhookModel = context.GetWorkflowInput(WebhookSerializerOptions.Create()); + var callInitiatedPayload = (CallInitiatedPayload)webhookModel.Data.Payload; + + // Store webhook payload as output. + Result.Set(context, callInitiatedPayload); + + await context.CompleteActivityAsync(); + } + + private IEnumerable GetBookmarkPayloads(ExpressionExecutionContext context) + { + var from = context.Get(From) ?? ArraySegment.Empty; + var to = context.Get(To) ?? ArraySegment.Empty; + var catchAll = context.Get(CatchAll); + + foreach (var phoneNumber in from) yield return new IncomingCallFromStimulus(phoneNumber); + foreach (var phoneNumber in to) yield return new IncomingCallToStimulus(phoneNumber); + + if (catchAll) + yield return new IncomingCallCatchAllStimulus(); + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/LookupNumber.cs b/src/Elsa.Integrations.Telnyx/Activities/LookupNumber.cs new file mode 100644 index 00000000..3633a6b3 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/LookupNumber.cs @@ -0,0 +1,48 @@ +using System.Runtime.CompilerServices; +using Elsa.Extensions; +using Elsa.Integrations.Telnyx.Client.Models; +using Elsa.Integrations.Telnyx.Client.Services; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using Elsa.Workflows.UIHints; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +/// Returns information about the provided phone number. +/// +[Activity(Constants.Namespace, "Returns information about the provided phone number.", Kind = ActivityKind.Task)] +public class LookupNumber : CodeActivity +{ + /// + public LookupNumber([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + } + + /// + /// The phone number to be looked up. + /// + [Input(Description = "The phone number to be looked up.")] + public Input PhoneNumber { get; set; } = null!; + + /// + /// The types of number lookups to be performed. + /// + [Input( + Description = "The types of number lookups to be performed.", + UIHint = InputUIHints.CheckList, + Options = new[] { "carrier", "caller-name" } + )] + public Input> Types { get; set; } = new(new List()); + + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var telnyxClient = context.GetRequiredService(); + var phoneNumber = PhoneNumber.Get(context) ?? throw new("PhoneNumber is required."); + var types = Types.Get(context); + var response = await telnyxClient.NumberLookup.NumberLookupAsync(phoneNumber, types, context.CancellationToken); + context.Set(Result, response.Data); + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/PlayAudio.cs b/src/Elsa.Integrations.Telnyx/Activities/PlayAudio.cs new file mode 100644 index 00000000..fcd58882 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/PlayAudio.cs @@ -0,0 +1,29 @@ +using System.Runtime.CompilerServices; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using JetBrains.Annotations; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +[PublicAPI] +public class PlayAudio : PlayAudioBase +{ + /// + public PlayAudio([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + } + + /// + /// The to execute when the call was no longer active. + /// + [Port] public IActivity? Disconnected { get; set; } + + /// + protected override async ValueTask HandlePlaybackStartedAsync(ActivityExecutionContext context) => await context.CompleteActivityAsync(); + + /// + protected override async ValueTask HandleDisconnectedAsync(ActivityExecutionContext context) => await context.ScheduleActivityAsync(Disconnected, OnCompletedAsync); + + private async ValueTask OnCompletedAsync(ActivityCompletedContext context) => await context.TargetContext.CompleteActivityAsync(); +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/PlayAudioBase.cs b/src/Elsa.Integrations.Telnyx/Activities/PlayAudioBase.cs new file mode 100644 index 00000000..23337531 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/PlayAudioBase.cs @@ -0,0 +1,121 @@ +using System.Runtime.CompilerServices; +using Elsa.Extensions; +using Elsa.Integrations.Telnyx.Attributes; +using Elsa.Integrations.Telnyx.Bookmarks; +using Elsa.Integrations.Telnyx.Client.Models; +using Elsa.Integrations.Telnyx.Client.Services; +using Elsa.Integrations.Telnyx.Extensions; +using Elsa.Workflows; +using Elsa.Workflows.Activities.Flowchart.Attributes; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using Elsa.Workflows.UIHints; +using Refit; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +/// Play an audio file on the call. +/// +[Activity(Constants.Namespace, "Play an audio file on the call.", Kind = ActivityKind.Task)] +[FlowNode("Playback started", "Disconnected")] +[WebhookDriven(WebhookEventTypes.CallPlaybackStarted)] +public abstract class PlayAudioBase : Activity +{ + /// + protected PlayAudioBase([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + } + + /// + /// Unique identifier and token for controlling the call. + /// + [Input( + DisplayName = "Call Control ID", + Description = "Unique identifier and token for controlling the call.", + Category = "Advanced" + )] + public Input CallControlId { get; set; } = null!; + + /// + /// The URL of a file to be played back at the beginning of each prompt. The URL can point to either a WAV or MP3 file. + /// + [Input( + DisplayName = "Audio URL", + Description = "The URL of a file to be played back at the beginning of each prompt. The URL can point to either a WAV or MP3 file." + )] + public Input AudioUrl { get; set; } = null!; + + /// + /// The number of times the audio file should be played. If supplied, the value must be an integer between 1 and 100, or the special string 'infinity' for an endless loop. + /// + [Input( + Description = "The number of times the audio file should be played. If supplied, the value must be an integer between 1 and 100, or the special string 'infinity' for an endless loop.", + DefaultValue = "1", + Category = "Advanced" + )] + public Input Loop { get; set; } = new("1"); + + /// + /// When enabled, audio will be mixed on top of any other audio that is actively being played back. Note that `overlay: true` will only work if there is another audio file already being played on the call. + /// + [Input( + Description = "When enabled, audio will be mixed on top of any other audio that is actively being played back. Note that `overlay: true` will only work if there is another audio file already being played on the call.", + DefaultValue = false, + Category = "Advanced" + )] + public Input Overlay { get; set; } = new(false); + + /// + /// Specifies the leg or legs on which audio will be played. If supplied, the value must be either 'self', 'opposite' or 'both'. + /// + [Input( + Description = "Specifies the leg or legs on which audio will be played. If supplied, the value must be either 'self', 'opposite' or 'both'.", + UIHint = InputUIHints.DropDown, + Options = new[] { "", "self", "opposite", "both" }, + Category = "Advanced" + )] + public Input TargetLegs { get; set; } = null!; + + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var loop = Loop.GetOrDefault(context); + + var request = new PlayAudioRequest( + AudioUrl.Get(context), + Overlay.GetOrDefault(context), + string.IsNullOrWhiteSpace(loop) ? null : loop == "infinity" ? "infinity" : int.Parse(loop), + TargetLegs.GetOrDefault(context).EmptyToNull(), + ClientState: context.CreateCorrelatingClientState(context.Id) + ); + + var callControlId = CallControlId.Get(context); + var telnyxClient = context.GetRequiredService(); + + try + { + await telnyxClient.Calls.PlayAudioAsync(callControlId, request, context.CancellationToken); + } + catch (ApiException e) + { + if (!await e.CallIsNoLongerActiveAsync()) throw; + await HandleDisconnectedAsync(context); + } + + context.CreateBookmark(new WebhookEventStimulus(WebhookEventTypes.CallPlaybackStarted, callControlId), ResumeAsync, true); + } + + /// + /// Called when playback has started. + /// + protected abstract ValueTask HandlePlaybackStartedAsync(ActivityExecutionContext context); + + + /// + /// Called when the call was no longer active. + /// + protected abstract ValueTask HandleDisconnectedAsync(ActivityExecutionContext context); + + private async ValueTask ResumeAsync(ActivityExecutionContext context) => await HandlePlaybackStartedAsync(context); +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/SpeakText.cs b/src/Elsa.Integrations.Telnyx/Activities/SpeakText.cs new file mode 100644 index 00000000..689bfb2b --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/SpeakText.cs @@ -0,0 +1,32 @@ +using System.Runtime.CompilerServices; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using JetBrains.Annotations; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +[PublicAPI] +public class SpeakText : SpeakTextBase +{ + /// + public SpeakText([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + } + + /// + /// The to execute when the call was no longer active. + /// + [Port] public IActivity? Disconnected { get; set; } + + /// + /// The to execute when speaking has finished. + /// + [Port] public IActivity? FinishedSpeaking { get; set; } + + /// + protected override async ValueTask HandleDisconnected(ActivityExecutionContext context) => await context.ScheduleActivityAsync(Disconnected); + + /// + protected override async ValueTask HandleSpeakingHasFinished(ActivityExecutionContext context) => await context.ScheduleActivityAsync(FinishedSpeaking); +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/SpeakTextBase.cs b/src/Elsa.Integrations.Telnyx/Activities/SpeakTextBase.cs new file mode 100644 index 00000000..78c80fa5 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/SpeakTextBase.cs @@ -0,0 +1,128 @@ +using Elsa.Extensions; +using Elsa.Integrations.Telnyx.Attributes; +using Elsa.Integrations.Telnyx.Bookmarks; +using Elsa.Integrations.Telnyx.Client.Models; +using Elsa.Integrations.Telnyx.Client.Services; +using Elsa.Integrations.Telnyx.Extensions; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using Elsa.Workflows.UIHints; +using Refit; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +/// Convert text to speech and play it back on the call. +/// +[Activity(Constants.Namespace, "Convert text to speech and play it back on the call.", Kind = ActivityKind.Task)] +[WebhookDriven(WebhookEventTypes.CallSpeakEnded)] +public abstract class SpeakTextBase : Activity +{ + /// + protected SpeakTextBase(string? source = null, int? line = null) : base(source, line) + { + } + + /// + /// Unique identifier and token for controlling the call. + /// + [Input( + DisplayName = "Call Control ID", + Description = "Unique identifier and token for controlling the call.", + Category = "Advanced" + )] + public Input CallControlId { get; set; } = null!; + + /// + /// The language you want spoken. + /// + [Input( + Description = "The language you want spoken.", + UIHint = InputUIHints.DropDown, + Options = new[] { "en-US", "en-AU", "nl-NL", "es-ES", "ru-RU" }, + DefaultValue = "en-US" + )] + public Input Language { get; set; } = new("en-US"); + + /// + /// The gender of the voice used to speak back the text. + /// + [Input( + Description = "The gender of the voice used to speak back the text.", + UIHint = InputUIHints.DropDown, + Options = new[] { "female", "male" }, + DefaultValue = "female" + )] + public Input Voice { get; set; } = new("female"); + + /// + /// The text or SSML to be converted into speech. There is a 5,000 character limit. + /// + [Input( + Description = "The text or SSML to be converted into speech. There is a 5,000 character limit.", + UIHint = InputUIHints.MultiLine + )] + public Input Payload { get; set; } = null!; + + /// + /// The type of the provided payload. The payload can either be plain text, or Speech Synthesis Markup Language (SSML). + /// + [Input( + Description = "The type of the provided payload. The payload can either be plain text, or Speech Synthesis Markup Language (SSML).", + UIHint = InputUIHints.DropDown, + Options = new[] { "", "text", "ssml" } + )] + public Input? PayloadType { get; set; } + + /// + /// This parameter impacts speech quality, language options and payload types. When using `basic`, only the `en-US` language and payload type `text` are allowed. + /// + [Input( + Description = "This parameter impacts speech quality, language options and payload types. When using `basic`, only the `en-US` language and payload type `text` are allowed.", + UIHint = InputUIHints.DropDown, + Options = new[] { "", "basic", "premium" }, + Category = "Advanced" + )] + public Input ServiceLevel { get; set; } = null!; + + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var request = new SpeakTextRequest( + Language.GetOrDefault(context) ?? "en-US", + Voice.GetOrDefault(context) ?? "female", + Payload.Get(context), + PayloadType.GetOrDefault(context).EmptyToNull(), + ServiceLevel.GetOrDefault(context).EmptyToNull(), + ClientState: context.CreateCorrelatingClientState(context.Id) + ); + + var callControlId = CallControlId.Get(context); + var telnyxClient = context.GetRequiredService(); + + try + { + // Send the request to Telnyx. + await telnyxClient.Calls.SpeakTextAsync(callControlId, request, context.CancellationToken); + + // Create bookmark to resume the workflow when speaking has finished. + context.CreateBookmark(new WebhookEventStimulus(WebhookEventTypes.CallSpeakEnded, callControlId), HandleSpeakingHasFinished, true); + } + catch (ApiException e) + { + if (!await e.CallIsNoLongerActiveAsync()) throw; + await HandleDisconnected(context); + } + } + + /// + /// Called when the call was no longer active. + /// + protected abstract ValueTask HandleDisconnected(ActivityExecutionContext context); + + /// + /// Called when speaking has finished. + /// + protected abstract ValueTask HandleSpeakingHasFinished(ActivityExecutionContext context); +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/StartRecording.cs b/src/Elsa.Integrations.Telnyx/Activities/StartRecording.cs new file mode 100644 index 00000000..2520e8df --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/StartRecording.cs @@ -0,0 +1,34 @@ +using System.Runtime.CompilerServices; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using JetBrains.Annotations; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +[PublicAPI] +public class StartRecording : StartRecordingBase +{ + /// + public StartRecording([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + } + + /// + /// The to execute when recording has finished. + /// + [Port] public IActivity? RecordingFinished { get; set; } + + /// + /// The to executed when the call was no longer active. + /// + [Port] public IActivity? Disconnected { get; set; } + + /// + protected override async ValueTask HandleCallRecordingSavedAsync(ActivityExecutionContext context) => await context.ScheduleActivityAsync(RecordingFinished, OnCompletedAsync); + + /// + protected override async ValueTask HandleDisconnectedAsync(ActivityExecutionContext context) => await context.ScheduleActivityAsync(Disconnected, OnCompletedAsync); + + private async ValueTask OnCompletedAsync(ActivityCompletedContext context) => await context.TargetContext.CompleteActivityAsync(); +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/StartRecordingBase.cs b/src/Elsa.Integrations.Telnyx/Activities/StartRecordingBase.cs new file mode 100644 index 00000000..d5981f54 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/StartRecordingBase.cs @@ -0,0 +1,109 @@ +using Elsa.Extensions; +using Elsa.Integrations.Telnyx.Attributes; +using Elsa.Integrations.Telnyx.Bookmarks; +using Elsa.Integrations.Telnyx.Client.Models; +using Elsa.Integrations.Telnyx.Client.Services; +using Elsa.Integrations.Telnyx.Extensions; +using Elsa.Integrations.Telnyx.Payloads.Call; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using Elsa.Workflows.UIHints; +using Refit; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +/// Start recording the call. +/// +[Activity(Constants.Namespace, "Start recording the call.", Kind = ActivityKind.Task)] +[WebhookDriven(WebhookEventTypes.CallRecordingSaved)] +public abstract class StartRecordingBase : Activity +{ + /// + protected StartRecordingBase(string? source = null, int? line = null) : base(source, line) + { + } + + /// + /// Unique identifier and token for controlling the call. + /// + [Input( + DisplayName = "Call Control ID", + Description = "Unique identifier and token for controlling the call.", + Category = "Advanced" + )] + public Input CallControlId { get; set; } = null!; + + /// + /// When 'dual', final audio file will be stereo recorded with the first leg on channel A, and the rest on channel B. + /// + [Input( + Description = "When 'dual', final audio file will be stereo recorded with the first leg on channel A, and the rest on channel B.", + UIHint = InputUIHints.DropDown, + Options = new[] { "single", "dual" }, + DefaultValue = "single" + )] + public Input Channels { get; set; } = new("single"); + + /// + /// The audio file format used when storing the call recording. Can be either 'mp3' or 'wav'. + /// + [Input( + Description = "The audio file format used when storing the call recording. Can be either 'mp3' or 'wav'.", + UIHint = InputUIHints.DropDown, + Options = new[] { "wav", "mp3" }, + DefaultValue = "wav" + )] + public Input Format { get; set; } = new("wav"); + + /// + /// If enabled, a beep sound will be played at the start of a recording. + /// + [Input(Description = "If enabled, a beep sound will be played at the start of a recording.")] + public Input? PlayBeep { get; set; } + + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var request = new StartRecordingRequest( + Channels.GetOrDefault(context) ?? "single", + Format.GetOrDefault(context) ?? "wav", + PlayBeep.GetOrDefault(context), + ClientState: context.CreateCorrelatingClientState() + ); + + var callControlId = CallControlId.Get(context); + var telnyxClient = context.GetRequiredService(); + + try + { + await telnyxClient.Calls.StartRecordingAsync(callControlId, request, context.CancellationToken); + + context.CreateBookmark(new WebhookEventStimulus(WebhookEventTypes.CallRecordingSaved, callControlId), ResumeAsync, false); + } + catch (ApiException e) + { + if (!await e.CallIsNoLongerActiveAsync()) throw; + await HandleDisconnectedAsync(context); + } + } + + /// + /// Called when the recording was saved. + /// + protected abstract ValueTask HandleCallRecordingSavedAsync(ActivityExecutionContext context); + + + /// + /// Called when the call was no longer active. + /// + protected abstract ValueTask HandleDisconnectedAsync(ActivityExecutionContext context); + + private async ValueTask ResumeAsync(ActivityExecutionContext context) + { + var payload = context.GetWorkflowInput(); + context.Set(Result, payload); + await HandleCallRecordingSavedAsync(context); + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/StopAudioPlayback.cs b/src/Elsa.Integrations.Telnyx/Activities/StopAudioPlayback.cs new file mode 100644 index 00000000..bd5fd1db --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/StopAudioPlayback.cs @@ -0,0 +1,27 @@ +using System.Runtime.CompilerServices; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +public class StopAudioPlayback : StopAudioPlaybackBase +{ + /// + public StopAudioPlayback([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + } + + /// + /// The to execute when the call was no longer active. + /// + [Port] public IActivity? Disconnected { get; set; } + + /// + protected override async ValueTask HandleDoneAsync(ActivityExecutionContext context) => await context.CompleteActivityAsync(); + + /// + protected override async ValueTask HandleDisconnectedAsync(ActivityExecutionContext context) => await context.ScheduleActivityAsync(Disconnected, OnCompletedAsync); + + private async ValueTask OnCompletedAsync(ActivityCompletedContext context) => await context.TargetContext.CompleteActivityAsync(); +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/StopAudioPlaybackBase.cs b/src/Elsa.Integrations.Telnyx/Activities/StopAudioPlaybackBase.cs new file mode 100644 index 00000000..931b06a1 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/StopAudioPlaybackBase.cs @@ -0,0 +1,71 @@ +using Elsa.Extensions; +using Elsa.Integrations.Telnyx.Client.Models; +using Elsa.Integrations.Telnyx.Client.Services; +using Elsa.Integrations.Telnyx.Extensions; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using Refit; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +/// Stop audio playback. +/// +[Activity(Constants.Namespace, Description = "Stop audio playback.", Kind = ActivityKind.Task)] +public abstract class StopAudioPlaybackBase : Activity +{ + /// + protected StopAudioPlaybackBase(string? source = null, int? line = null) : base(source, line) + { + } + + /// + /// Unique identifier and token for controlling the call. + /// + [Input( + DisplayName = "Call Control ID", + Description = "Unique identifier and token for controlling the call.", + Category = "Advanced" + )] + public Input CallControlId { get; set; } = null!; + + /// + /// Use 'current' to stop only the current audio or 'all' to stop all audios in the queue. + /// + [Input( + Description = "Use 'current' to stop only the current audio or 'all' to stop all audios in the queue.", + DefaultValue = "all", + Category = "Advanced" + )] + public Input Stop { get; set; } = new("all"); + + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var request = new StopAudioPlaybackRequest(Stop.Get(context), context.CreateCorrelatingClientState(context.Id)); + var callControlId = CallControlId.Get(context); + var telnyxClient = context.GetRequiredService(); + + try + { + await telnyxClient.Calls.StopAudioPlaybackAsync(callControlId, request, context.CancellationToken); + await HandleDoneAsync(context); + } + catch (ApiException e) + { + if (!await e.CallIsNoLongerActiveAsync()) throw; + await HandleDisconnectedAsync(context); + } + } + + /// + /// Called when audio playback is stopping. + /// + protected abstract ValueTask HandleDoneAsync(ActivityExecutionContext context); + + /// + /// Called when the call was no longer active. + /// + protected abstract ValueTask HandleDisconnectedAsync(ActivityExecutionContext context); +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/StopRecording.cs b/src/Elsa.Integrations.Telnyx/Activities/StopRecording.cs new file mode 100644 index 00000000..9bfc626f --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/StopRecording.cs @@ -0,0 +1,54 @@ +using System.Runtime.CompilerServices; +using Elsa.Extensions; +using Elsa.Integrations.Telnyx.Client.Models; +using Elsa.Integrations.Telnyx.Client.Services; +using Elsa.Integrations.Telnyx.Extensions; +using Elsa.Workflows; +using Elsa.Workflows.Activities.Flowchart.Attributes; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using Refit; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +/// Stop recording the call. +/// +[Activity(Constants.Namespace, "Stop recording the call.", Kind = ActivityKind.Task)] +[FlowNode("Recording stopped", "Disconnected")] +public class StopRecording : Activity +{ + /// + public StopRecording([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + } + + /// + /// Unique identifier and token for controlling the call. + /// + [Input( + DisplayName = "Call Control ID", + Description = "Unique identifier and token for controlling the call.", + Category = "Advanced" + )] + public Input CallControlId { get; set; } = null!; + + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var request = new StopRecordingRequest(context.CreateCorrelatingClientState()); + var callControlId = CallControlId.Get(context); + var telnyxClient = context.GetRequiredService(); + + try + { + await telnyxClient.Calls.StopRecordingAsync(callControlId, request, context.CancellationToken); + await context.CompleteActivityWithOutcomesAsync("Recording stopped"); + } + catch (ApiException e) + { + if (!await e.CallIsNoLongerActiveAsync()) throw; + await context.CompleteActivityWithOutcomesAsync("Disconnected"); + } + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/TransferCall.cs b/src/Elsa.Integrations.Telnyx/Activities/TransferCall.cs new file mode 100644 index 00000000..65418ca7 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/TransferCall.cs @@ -0,0 +1,162 @@ +using System.Runtime.CompilerServices; +using Elsa.Extensions; +using Elsa.Integrations.Telnyx.Attributes; +using Elsa.Integrations.Telnyx.Bookmarks; +using Elsa.Integrations.Telnyx.Client.Models; +using Elsa.Integrations.Telnyx.Client.Services; +using Elsa.Integrations.Telnyx.Extensions; +using Elsa.Integrations.Telnyx.Payloads.Abstractions; +using Elsa.Integrations.Telnyx.Payloads.Call; +using Elsa.Workflows; +using Elsa.Workflows.Activities.Flowchart.Attributes; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using Elsa.Workflows.UIHints; +using Refit; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +/// Transfer a call to a new destination. +/// +[Activity(Constants.Namespace, "Transfer a call to a new destination.", Kind = ActivityKind.Task)] +[FlowNode("Transferred", "Hangup", "Disconnected")] +[WebhookDriven(WebhookEventTypes.CallInitiated, WebhookEventTypes.CallAnswered, WebhookEventTypes.CallHangup)] +public class TransferCall : Activity +{ + /// + public TransferCall([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + } + + /// + /// Unique identifier and token for controlling the call. + /// + [Input( + Name = "Call Control ID", + Description = "Unique identifier and token for controlling the call.", + Category = "Advanced" + )] + public Input CallControlId { get; set; } = null!; + + /// + /// The DID or SIP URI to dial out and bridge to the given call. + /// + [Input(Description = "The DID or SIP URI to dial out and bridge to the given call.")] + public Input To { get; set; } = null!; + + /// + /// The 'from' number to be used as the caller id presented to the destination ('To' number). The number should be in +E164 format. This attribute will default to the 'From' number of the original call if omitted. + /// + [Input(Description = "The 'from' number to be used as the caller id presented to the destination ('To' number). The number should be in +E164 format. This attribute will default to the 'From' number of the original call if omitted.")] + public Input From { get; set; } = null!; + + /// + /// The string to be used as the caller id name (SIP From Display Name) presented to the destination ('To' number). The string should have a maximum of 128 characters, containing only letters, numbers, spaces, and -_~!.+ special characters. If omitted, the display name will be the same as the number in the 'From' field. + /// + [Input(Description = + "The string to be used as the caller id name (SIP From Display Name) presented to the destination ('To' number). The string should have a maximum of 128 characters, containing only letters, numbers, spaces, and -_~!.+ special characters. If omitted, the display name will be the same as the number in the 'From' field." + )] + public Input FromDisplayName { get; set; } = null!; + + /// + /// Enables Answering Machine Detection. + /// + [Input( + DisplayName = "Answering Machine Detection", + Description = "Enables Answering Machine Detection.", + UIHint = InputUIHints.DropDown, + Options = new[] { "disabled", "detect", "detect_beep", "detect_words", "greeting_end" } + )] + public Input AnsweringMachineDetection { get; set; } = null!; + + /// + /// Audio URL to be played back when the transfer destination answers before bridging the call. The URL can point to either a WAV or MP3 file. + /// + [Input( + DisplayName = "Audio URL", + Description = "Audio URL to be played back when the transfer destination answers before bridging the call. The URL can point to either a WAV or MP3 file." + )] + public Input AudioUrl { get; set; } = null!; + + /// + /// Sets the maximum duration of a Call Control Leg in seconds. + /// + [Input(DisplayName = "Time limit", Description = "Sets the maximum duration of a Call Control Leg in seconds.", Category = "Advanced")] + public Input TimeLimitSecs { get; set; } = null!; + + /// + /// The number of seconds that Telnyx will wait for the call to be answered by the destination to which it is being transferred. + /// + [Input( + DisplayName = "Timeout", + Description = "The number of seconds that Telnyx will wait for the call to be answered by the destination to which it is being transferred.", + Category = "Advanced" + )] + public Input TimeoutSecs { get; set; } = null!; + + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + await TransferCallAsync(context); + + var initiatedBookmark = new WebhookEventStimulus(WebhookEventTypes.CallInitiated); + context.CreateBookmark(initiatedBookmark, InitiatedAsync); + } + + private ValueTask InitiatedAsync(ActivityExecutionContext context) + { + var payload = context.GetWorkflowInput(); + var callControlId = payload.CallControlId; + var answeredBookmark = new WebhookEventStimulus(WebhookEventTypes.CallAnswered, callControlId); + var hangupBookmark = new WebhookEventStimulus(WebhookEventTypes.CallHangup, callControlId); + context.CreateBookmark(answeredBookmark, AnsweredAsync, false); + context.CreateBookmark(hangupBookmark, HangupAsync, false); + return default; + } + + private async ValueTask AnsweredAsync(ActivityExecutionContext context) + { + var payload = context.GetWorkflowInput(); + Result.Set(context, payload); + await context.CompleteActivityWithOutcomesAsync("Transferred"); + } + + private async ValueTask HangupAsync(ActivityExecutionContext context) + { + var payload = context.GetWorkflowInput(); + Result.Set(context, payload); + await context.CompleteActivityWithOutcomesAsync("Hangup"); + } + + private async ValueTask TransferCallAsync(ActivityExecutionContext context) + { + var callControlId = CallControlId.Get(context); + + var request = new TransferCallRequest( + To.Get(context) ?? throw new("To is required."), + From.GetOrDefault(context), + FromDisplayName.GetOrDefault(context).SanitizeCallerName(), + AudioUrl.GetOrDefault(context), + AnsweringMachineDetection.GetOrDefault(context), + null, + TimeLimitSecs.GetOrDefault(context), + TimeoutSecs.GetOrDefault(context), + ClientState: context.CreateCorrelatingClientState(context.Id), + TargetLegClientState: context.CreateCorrelatingClientState(context.Id) + ); + + + var telnyxClient = context.GetRequiredService(); + + try + { + await telnyxClient.Calls.TransferCallAsync(callControlId, request, context.CancellationToken); + } + catch (ApiException e) + { + if (!await e.CallIsNoLongerActiveAsync(context.CancellationToken)) throw; + await context.CompleteActivityWithOutcomesAsync("Disconnected"); + } + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Activities/WebhookEvent.cs b/src/Elsa.Integrations.Telnyx/Activities/WebhookEvent.cs new file mode 100644 index 00000000..49fdfbba --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Activities/WebhookEvent.cs @@ -0,0 +1,67 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using Elsa.Extensions; +using Elsa.Integrations.Telnyx.Bookmarks; +using Elsa.Integrations.Telnyx.Helpers; +using Elsa.Integrations.Telnyx.Models; +using Elsa.Integrations.Telnyx.Payloads.Abstractions; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Memory; +using JetBrains.Annotations; + +namespace Elsa.Integrations.Telnyx.Activities; + +/// +/// Represents a Telnyx webhook event trigger. +/// +[Activity("Telnyx", "Telnyx", "A Telnyx webhook event that executes when a webhook event is received.", Kind = ActivityKind.Trigger)] +[Browsable(false)] +[UsedImplicitly] +public class WebhookEvent : Activity +{ + /// + public WebhookEvent([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + } + + /// + public WebhookEvent(string eventType, string activityTypeName, Variable result, int version = 1, [CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) + : base(activityTypeName, version, source, line) + { + EventType = eventType; + Result = new(result); + } + + /// + /// The Telnyx webhook event type to listen for. + /// + [Description("The Telnyx webhook event type to listen for")] + public string EventType { get; set; } = null!; + + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + if (context.IsTriggerOfWorkflow()) + await Resume(context); + else + { + var eventType = EventType; + var payload = new WebhookEventStimulus(eventType); + + context.CreateBookmark(new() + { + Stimulus = payload, + Callback = Resume, + BookmarkName = Type + }); + } + } + + private async ValueTask Resume(ActivityExecutionContext context) + { + var input = context.GetWorkflowInput(WebhookSerializerOptions.Create()); + context.Set(Result, input.Data.Payload); + await CompleteAsync(context); + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Attributes/WebhookActivityAttribute.cs b/src/Elsa.Integrations.Telnyx/Attributes/WebhookActivityAttribute.cs new file mode 100644 index 00000000..ebb54340 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Attributes/WebhookActivityAttribute.cs @@ -0,0 +1,33 @@ +using Elsa.Integrations.Telnyx.Payloads.Abstractions; + +namespace Elsa.Integrations.Telnyx.Attributes; + +/// +/// Contains metadata about the activity descriptor to yield from the annotated payload. +/// +[AttributeUsage(AttributeTargets.Class)] +public class WebhookActivityAttribute : WebhookAttribute +{ + /// + public WebhookActivityAttribute(string eventType, string activityType, string displayName, string description) : base(eventType) + { + ActivityType = activityType; + DisplayName = displayName; + Description = description; + } + + /// + /// The activity type name to yield for the annotated type. + /// + public string ActivityType { get; } + + /// + /// The activity display name to yield for the annotated type. + /// + public string DisplayName { get; } + + /// + /// The activity description to yield for the annotated type. + /// + public string Description { get; } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Attributes/WebhookAttribute.cs b/src/Elsa.Integrations.Telnyx/Attributes/WebhookAttribute.cs new file mode 100644 index 00000000..2b16c5d6 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Attributes/WebhookAttribute.cs @@ -0,0 +1,21 @@ +using Elsa.Integrations.Telnyx.Payloads.Abstractions; + +namespace Elsa.Integrations.Telnyx.Attributes; + +/// +/// Used to handle a Telnyx webhook event. +/// +[AttributeUsage(AttributeTargets.Class)] +public class WebhookAttribute : Attribute +{ + /// + public WebhookAttribute(string eventType) + { + EventType = eventType; + } + + /// + /// The Telnyx event to match to th annotated type. + /// + public string EventType { get; } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Attributes/WebhookDrivenAttribute.cs b/src/Elsa.Integrations.Telnyx/Attributes/WebhookDrivenAttribute.cs new file mode 100644 index 00000000..ad042234 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Attributes/WebhookDrivenAttribute.cs @@ -0,0 +1,19 @@ +namespace Elsa.Integrations.Telnyx.Attributes; + +/// +/// Contains metadata about the activity descriptor to yield from the annotated payload. +/// +[AttributeUsage(AttributeTargets.Class)] +public class WebhookDrivenAttribute : Attribute +{ + /// + public WebhookDrivenAttribute(params string[] eventTypes) + { + EventTypes = new HashSet(eventTypes); + } + + /// + /// The Telnyx event to match. + /// + public ISet EventTypes { get; } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Bookmarks/AnswerCallStimulus.cs b/src/Elsa.Integrations.Telnyx/Bookmarks/AnswerCallStimulus.cs new file mode 100644 index 00000000..b794634b --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Bookmarks/AnswerCallStimulus.cs @@ -0,0 +1,7 @@ +namespace Elsa.Integrations.Telnyx.Bookmarks; + +/// +/// A bookmark payload for the call.answered Telnyx webhook event. +/// +/// The call control ID. +public record AnswerCallStimulus(string CallControlId); \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Bookmarks/CallAnsweredStimulus.cs b/src/Elsa.Integrations.Telnyx/Bookmarks/CallAnsweredStimulus.cs new file mode 100644 index 00000000..5cd4891e --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Bookmarks/CallAnsweredStimulus.cs @@ -0,0 +1,7 @@ +namespace Elsa.Integrations.Telnyx.Bookmarks; + +/// +/// A bookmark payload for the call.answered Telnyx webhook event. +/// +/// +public record CallAnsweredStimulus(string CallControlId); \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Bookmarks/CallHangupStimulus.cs b/src/Elsa.Integrations.Telnyx/Bookmarks/CallHangupStimulus.cs new file mode 100644 index 00000000..c7b8a67b --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Bookmarks/CallHangupStimulus.cs @@ -0,0 +1,7 @@ +namespace Elsa.Integrations.Telnyx.Bookmarks; + +/// +/// A bookmark payload for the call.hangup Telnyx webhook event. +/// +/// +public record CallHangupStimulus(string CallControlId); \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Bookmarks/IncomingCallCatchAllStimulus.cs b/src/Elsa.Integrations.Telnyx/Bookmarks/IncomingCallCatchAllStimulus.cs new file mode 100644 index 00000000..0a5d2cfe --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Bookmarks/IncomingCallCatchAllStimulus.cs @@ -0,0 +1,3 @@ +namespace Elsa.Integrations.Telnyx.Bookmarks; + +public record IncomingCallCatchAllStimulus; \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Bookmarks/IncomingCallFromStimulus.cs b/src/Elsa.Integrations.Telnyx/Bookmarks/IncomingCallFromStimulus.cs new file mode 100644 index 00000000..24d2d7a4 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Bookmarks/IncomingCallFromStimulus.cs @@ -0,0 +1,3 @@ +namespace Elsa.Integrations.Telnyx.Bookmarks; + +public record IncomingCallFromStimulus(string PhoneNumber); \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Bookmarks/IncomingCallToStimulus.cs b/src/Elsa.Integrations.Telnyx/Bookmarks/IncomingCallToStimulus.cs new file mode 100644 index 00000000..c7d150f7 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Bookmarks/IncomingCallToStimulus.cs @@ -0,0 +1,3 @@ +namespace Elsa.Integrations.Telnyx.Bookmarks; + +public record IncomingCallToStimulus(string PhoneNumber); \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Bookmarks/WebhookEventStimulus.cs b/src/Elsa.Integrations.Telnyx/Bookmarks/WebhookEventStimulus.cs new file mode 100644 index 00000000..f0de77a8 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Bookmarks/WebhookEventStimulus.cs @@ -0,0 +1,8 @@ +namespace Elsa.Integrations.Telnyx.Bookmarks; + +/// +/// A bookmark payload for Telnyx webhook events. +/// +/// The event type. +/// An optional call control ID. +public record WebhookEventStimulus(string EventType, string? CallControlId = null); \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Client/Implementations/TelnyxClient.cs b/src/Elsa.Integrations.Telnyx/Client/Implementations/TelnyxClient.cs new file mode 100644 index 00000000..d3e8f2b2 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Client/Implementations/TelnyxClient.cs @@ -0,0 +1,28 @@ +using Elsa.Integrations.Telnyx.Client.Services; + +namespace Elsa.Integrations.Telnyx.Client.Implementations; + +/// +/// Represents a Telnyx API client. +/// +public class TelnyxClient : ITelnyxClient +{ + /// + /// Constructor. + /// + public TelnyxClient(ICallsApi calls, INumberLookupApi numberLookup) + { + Calls = calls; + NumberLookup = numberLookup; + } + + /// + /// Provides access to the Calls API. + /// + public ICallsApi Calls { get; } + + /// + /// Provides access to the Number Lookup API. + /// + public INumberLookupApi NumberLookup { get; } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Client/Models/AnsweringMachineConfig.cs b/src/Elsa.Integrations.Telnyx/Client/Models/AnsweringMachineConfig.cs new file mode 100644 index 00000000..dcb13c78 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Client/Models/AnsweringMachineConfig.cs @@ -0,0 +1,13 @@ +namespace Elsa.Integrations.Telnyx.Client.Models; + +public record AnsweringMachineConfig( + int AfterGreetingSilenceMillis = 800, + int BetweenWordsSilenceMillis = 50, + int GreetingDurationMillis = 3500, + int GreetingTotalAnalysisTimeMillis = 5000, + int InitialSilenceMillis = 3500, + int MaximumNumberOfWords = 5, + int MaximumWordLengthMillis = 3500, + int SilenceThreshold = 256, + int TotalAnalysisTimeMillis = 3500 +); \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Client/Models/Errors.cs b/src/Elsa.Integrations.Telnyx/Client/Models/Errors.cs new file mode 100644 index 00000000..86f93301 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Client/Models/Errors.cs @@ -0,0 +1,9 @@ +namespace Elsa.Integrations.Telnyx.Client.Models; + +public record ErrorResponse(IList Errors); +public record Error(string Code, string Title, string Detail); + +public static class ErrorCodes +{ + public const string CallHasAlreadyEnded = "90018"; +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Client/Models/Header.cs b/src/Elsa.Integrations.Telnyx/Client/Models/Header.cs new file mode 100644 index 00000000..bc564e51 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Client/Models/Header.cs @@ -0,0 +1,3 @@ +namespace Elsa.Integrations.Telnyx.Client.Models; + +public record Header(string Name, string Value); \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Client/Models/PlayAudioResponse.cs b/src/Elsa.Integrations.Telnyx/Client/Models/PlayAudioResponse.cs new file mode 100644 index 00000000..df80dbc5 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Client/Models/PlayAudioResponse.cs @@ -0,0 +1,3 @@ +namespace Elsa.Integrations.Telnyx.Client.Models; + +public record PlayAudioResponse(string Result); \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Client/Models/Requests.cs b/src/Elsa.Integrations.Telnyx/Client/Models/Requests.cs new file mode 100644 index 00000000..bb5335bb --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Client/Models/Requests.cs @@ -0,0 +1,126 @@ +namespace Elsa.Integrations.Telnyx.Client.Models; + +public record AnswerCallRequest( + string? BillingGroupId = null, + string? ClientState = null, + string? CommandId = null, + string? WebhookUrl = null, + string? WebhookUrlMethod = null); + +public record HangupCallRequest(string? ClientState = null, string? CommandId = null); + +public record GatherUsingAudioRequest( + Uri AudioUrl, + string? ClientState = null, + string? CommandId = null, + int? InterDigitTimeoutMillis = null, + Uri? InvalidAudioUrl = null, + int? MaximumDigits = null, + int? MaximumTries = null, + int? MinimumDigits = null, + string? TerminatingDigit = null, + int? TimeoutMillis = null, + string? ValidDigits = null +); + +public record GatherUsingSpeakRequest( + string Language, + string Voice, + string Payload, + string? PayloadType = null, + string? ServiceLevel = null, + int? InterDigitTimeoutMillis = null, + int? MaximumDigits = null, + int? MaximumTries = null, + int? MinimumDigits = null, + string? TerminatingDigit = null, + int? TimeoutMillis = null, + string? ValidDigits = null, + string? ClientState = null, + string? CommandId = null +); + +public record TransferCallRequest( + string To, + string? From = null, + string? FromDisplayName = null, + Uri? AudioUrl = null, + string? AnsweringMachineDetection = null, + AnsweringMachineConfig? AnsweringMachineConfig = null, + int? TimeLimitSecs = null, + int? TimeoutSecs = null, + string? TargetLegClientState = null, + IList
? CustomHeaders = null, + string? SipAuthUsername = null, + string? SipAuthPassword = null, + string? ClientState = null, + string? CommandId = null, + string? WebhookUrl = null, + string? WebhookUrlMethod = null +); + +public record DialRequest( + string ConnectionId, + string To, + string? From = null, + string? FromDisplayName = null, + string? AnsweringMachineDetection = null, + AnsweringMachineConfig? AnsweringMachineConfig = null, + string? Record = null, + string? RecordFormat = null, + string? ClientState = null, + string? CommandId = null, + IList
? CustomHeaders = null, + string? SipAuthUsername = null, + string? SipAuthPassword = null, + int? TimeLimitSecs = null, + int? TimeoutSecs = null, + string? WebhookUrl = null, + string? WebhookUrlMethod = null +); + +public record BridgeCallsRequest( + string CallControlId, + string? ClientState = null, + string? CommandId = null, + string? ParkAfterUnbridge = null +); + +public record PlayAudioRequest( + Uri AudioUrl, + bool Overlay, + object? Loop = null, + string? TargetLegs = null, + string? ClientState = null, + string? CommandId = null +); + +public record StopAudioPlaybackRequest( + string? Stop = null, + string? ClientState = null, + string? CommandId = null +); + +public record StartRecordingRequest( + string Channels, + string Format, + bool? PlayBeep = null, + string? ClientState = null, + string? CommandId = null +); + +public record StopRecordingRequest( + string? ClientState = null, + string? CommandId = null +); + +public record SpeakTextRequest( + string Language, + string Voice, + string Payload, + string? PayloadType = null, + string? ServiceLevel = null, + string? Stop = null, + string? ClientState = null, + string? CommandId = null +); \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Client/Models/Responses.cs b/src/Elsa.Integrations.Telnyx/Client/Models/Responses.cs new file mode 100644 index 00000000..4a00aeb5 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Client/Models/Responses.cs @@ -0,0 +1,87 @@ +using System.Text.Json.Serialization; + +namespace Elsa.Integrations.Telnyx.Client.Models; + +public record TelnyxResponse(T Data); + +public record CallStatusResponse( + string CallControlId, + string CallLegId, + string CallSessionId, + string ClientState, + bool IsAlive, + string RecordType +); + +public record DialResponse( + string CallControlId, + string CallLegId, + string CallSessionId, + bool IsAlive, + string RecordType +) +{ + [JsonConstructor] + public DialResponse() : this(null!, null!, null!, false, null!) + { + } +} + +public record NumberLookupResponse( + CallerName CallerName, + Carrier Carrier, + string CountryCode, + string Fraud, + string NationalFormat, + string PhoneNumber, + Portability Portability, + string RecordType +) +{ + [JsonConstructor] + public NumberLookupResponse() : this(null!, null!, null!, null!, null!, null!, null!, null!) + { + } +} + +public record Portability( + string Altspid, + string AltspidCarrierName, + string AltspidCarrierType, + string City, + string LineType, + string Lrn, + string Ocn, + string? PortedDate, + string PortedStatus, + string Spid, + string SpidCarrierName, + string SpidCarrierType, + string State +) +{ + [JsonConstructor] + public Portability() : this(null!, null!, null!, null!, null!, null!, null!, null, null!, null!, null!, null!, null!) + { + } +} + +public record Carrier( + string ErrorCode, + string MobileCountryCode, + string MobileNetworkCode, + string Name, + string Type +) +{ + [JsonConstructor] + public Carrier() : this(null!, null!, null, null!, null!) + { + } +} + +public record CallerName +{ + [JsonPropertyName("caller_name")] public string Name { get; set; } = null!; + public string ErrorCode { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Client/Services/ICallsApi.cs b/src/Elsa.Integrations.Telnyx/Client/Services/ICallsApi.cs new file mode 100644 index 00000000..1b764560 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Client/Services/ICallsApi.cs @@ -0,0 +1,46 @@ +using Elsa.Integrations.Telnyx.Client.Models; +using Refit; + +namespace Elsa.Integrations.Telnyx.Client.Services; + +public interface ICallsApi +{ + [Get("/v2/calls/{callControlId}")] + Task> GetStatusAsync(string callControlId, CancellationToken cancellationToken = default); + + [Post("/v2/calls")] + Task> DialAsync([Body] DialRequest request, CancellationToken cancellationToken = default); + + [Post("/v2/calls/{callControlId}/actions/answer")] + Task AnswerCallAsync(string callControlId, [Body] AnswerCallRequest request, CancellationToken cancellationToken = default); + + [Post("/v2/calls/{callControlId}/actions/transfer")] + Task TransferCallAsync(string callControlId, [Body] TransferCallRequest request, CancellationToken cancellationToken = default); + + [Post("/v2/calls/{callControlId}/actions/hangup")] + Task HangupCallAsync(string callControlId, [Body] HangupCallRequest request, CancellationToken cancellationToken = default); + + [Post("/v2/calls/{callControlId}/actions/gather_using_audio")] + Task GatherUsingAudioAsync(string callControlId, [Body] GatherUsingAudioRequest request, CancellationToken cancellationToken = default); + + [Post("/v2/calls/{callControlId}/actions/gather_using_speak")] + Task GatherUsingSpeakAsync(string callControlId, [Body] GatherUsingSpeakRequest request, CancellationToken cancellationToken = default); + + [Post("/v2/calls/{callControlId}/actions/bridge")] + Task BridgeCallsAsync(string callControlId, [Body] BridgeCallsRequest request, CancellationToken cancellationToken = default); + + [Post("/v2/calls/{callControlId}/actions/playback_start")] + Task> PlayAudioAsync(string callControlId, [Body] PlayAudioRequest request, CancellationToken cancellationToken = default); + + [Post("/v2/calls/{callControlId}/actions/playback_stop")] + Task StopAudioPlaybackAsync(string callControlId, [Body] StopAudioPlaybackRequest request, CancellationToken cancellationToken = default); + + [Post("/v2/calls/{callControlId}/actions/record_start")] + Task StartRecordingAsync(string callControlId, [Body] StartRecordingRequest request, CancellationToken cancellationToken = default); + + [Post("/v2/calls/{callControlId}/actions/record_stop")] + Task StopRecordingAsync(string callControlId, [Body] StopRecordingRequest request, CancellationToken cancellationToken = default); + + [Post("/v2/calls/{callControlId}/actions/speak")] + Task SpeakTextAsync(string callControlId, [Body] SpeakTextRequest request, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Client/Services/INumberLookupApi.cs b/src/Elsa.Integrations.Telnyx/Client/Services/INumberLookupApi.cs new file mode 100644 index 00000000..43fe237c --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Client/Services/INumberLookupApi.cs @@ -0,0 +1,14 @@ +using Elsa.Integrations.Telnyx.Client.Models; +using Refit; + +namespace Elsa.Integrations.Telnyx.Client.Services; + +public interface INumberLookupApi +{ + [Get("/v2/number_lookup/{phoneNumber}")] + Task> NumberLookupAsync( + string phoneNumber, + [Query(CollectionFormat.Multi)] [AliasAs("type")] + IEnumerable? types = null, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Client/Services/ITelnyxClient.cs b/src/Elsa.Integrations.Telnyx/Client/Services/ITelnyxClient.cs new file mode 100644 index 00000000..34396a08 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Client/Services/ITelnyxClient.cs @@ -0,0 +1,7 @@ +namespace Elsa.Integrations.Telnyx.Client.Services; + +public interface ITelnyxClient +{ + ICallsApi Calls { get; } + INumberLookupApi NumberLookup { get; } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Constants.cs b/src/Elsa.Integrations.Telnyx/Constants.cs new file mode 100644 index 00000000..8fcf92b7 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Constants.cs @@ -0,0 +1,17 @@ +namespace Elsa.Integrations.Telnyx; + +/// +/// Contains constants used by the Telnyx activities. +/// +public static class Constants +{ + /// + /// The namespace used by the Telnyx activities. + /// + public const string Namespace = "Telnyx"; + + /// + /// The category used by the Telnyx activities. + /// + public const string Category = "Telnyx"; +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Contracts/IWebhookHandler.cs b/src/Elsa.Integrations.Telnyx/Contracts/IWebhookHandler.cs new file mode 100644 index 00000000..e5f51020 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Contracts/IWebhookHandler.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Http; + +namespace Elsa.Integrations.Telnyx.Contracts; + +internal interface IWebhookHandler +{ + Task HandleAsync(HttpContext httpContext); +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Elsa.Integrations.Telnyx.csproj b/src/Elsa.Integrations.Telnyx/Elsa.Integrations.Telnyx.csproj new file mode 100644 index 00000000..108391db --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Elsa.Integrations.Telnyx.csproj @@ -0,0 +1,21 @@ + + + + + Provides integration with Telnyx. + + elsa module telephony telnyx + + + + + + + + + + + + + + diff --git a/src/Elsa.Integrations.Telnyx/Events/TelnyxWebhookReceived.cs b/src/Elsa.Integrations.Telnyx/Events/TelnyxWebhookReceived.cs new file mode 100644 index 00000000..93e8d735 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Events/TelnyxWebhookReceived.cs @@ -0,0 +1,24 @@ +using Elsa.Integrations.Telnyx.Models; +using Elsa.Mediator.Contracts; + +namespace Elsa.Integrations.Telnyx.Events; + +/// +/// Triggered when a Telnyx webhook is received. +/// +public class TelnyxWebhookReceived : INotification +{ + /// + /// Initializes a new instance of the class. + /// + public TelnyxWebhookReceived(TelnyxWebhook webhook) + { + Webhook = webhook; + } + + /// + /// Gets the webhook. + /// + public TelnyxWebhook Webhook { get; } + +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Exceptions/MissingCallControlAppIdException.cs b/src/Elsa.Integrations.Telnyx/Exceptions/MissingCallControlAppIdException.cs new file mode 100644 index 00000000..4a253081 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Exceptions/MissingCallControlAppIdException.cs @@ -0,0 +1,3 @@ +namespace Elsa.Integrations.Telnyx.Exceptions; + +public class MissingCallControlAppIdException(string message, Exception? innerException = null) : TelnyxException(message, innerException); \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Exceptions/MissingCallControlIdException.cs b/src/Elsa.Integrations.Telnyx/Exceptions/MissingCallControlIdException.cs new file mode 100644 index 00000000..e99345f4 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Exceptions/MissingCallControlIdException.cs @@ -0,0 +1,3 @@ +namespace Elsa.Integrations.Telnyx.Exceptions; + +public class MissingCallControlIdException(string message, Exception? innerException = null) : TelnyxException(message, innerException); \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Exceptions/MissingFromNumberException.cs b/src/Elsa.Integrations.Telnyx/Exceptions/MissingFromNumberException.cs new file mode 100644 index 00000000..0cfded34 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Exceptions/MissingFromNumberException.cs @@ -0,0 +1,3 @@ +namespace Elsa.Integrations.Telnyx.Exceptions; + +public class MissingFromNumberException(string message, Exception? innerException = null) : TelnyxException(message, innerException); \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Exceptions/TelnyxException.cs b/src/Elsa.Integrations.Telnyx/Exceptions/TelnyxException.cs new file mode 100644 index 00000000..a0360525 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Exceptions/TelnyxException.cs @@ -0,0 +1,3 @@ +namespace Elsa.Integrations.Telnyx.Exceptions; + +public class TelnyxException(string message, Exception? innerException = null) : Exception(message, innerException); \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Extensions/ActivityExecutionExtensions.cs b/src/Elsa.Integrations.Telnyx/Extensions/ActivityExecutionExtensions.cs new file mode 100644 index 00000000..16bba4ac --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Extensions/ActivityExecutionExtensions.cs @@ -0,0 +1,18 @@ +using Elsa.Integrations.Telnyx.Models; +using Elsa.Workflows; + +namespace Elsa.Integrations.Telnyx.Extensions; + +/// +/// Provides extensions on . +/// +public static class ActivityExecutionExtensions +{ + /// + /// Creates a correlating client state. + /// + public static string CreateCorrelatingClientState(this ActivityExecutionContext context, string? activityInstanceId = null) + { + return new ClientStatePayload(context.WorkflowExecutionContext.Id, activityInstanceId).ToBase64(); + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Extensions/ApiExceptionExtensions.cs b/src/Elsa.Integrations.Telnyx/Extensions/ApiExceptionExtensions.cs new file mode 100644 index 00000000..6881e748 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Extensions/ApiExceptionExtensions.cs @@ -0,0 +1,29 @@ +using Elsa.Integrations.Telnyx.Client.Models; +using Refit; + +namespace Elsa.Integrations.Telnyx.Extensions; + +/// +/// Provides extensions for . +/// +public static class ApiExceptionExtensions +{ + /// + /// Reads a from the specified . + /// + public static async Task GetErrorResponseAsync(this ApiException e, CancellationToken cancellationToken = default) + { + var httpContent = new StringContent(e.Content!); + return (await e.RefitSettings.ContentSerializer.FromHttpContentAsync(httpContent, cancellationToken))!; + } + + /// + /// Returns true if the specified exception represents a failure due to the call no longer being active. + /// + public static async Task CallIsNoLongerActiveAsync(this ApiException e, CancellationToken cancellationToken = default) + { + var errorResponse = await e.GetErrorResponseAsync(cancellationToken); + var errors = errorResponse.Errors; + return errors.Any(x => x.Code == ErrorCodes.CallHasAlreadyEnded); + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Extensions/DependencyInjectionExtensions.cs b/src/Elsa.Integrations.Telnyx/Extensions/DependencyInjectionExtensions.cs new file mode 100644 index 00000000..44c547ac --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Extensions/DependencyInjectionExtensions.cs @@ -0,0 +1,102 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Elsa.Integrations.Telnyx.Client.Implementations; +using Elsa.Integrations.Telnyx.Client.Services; +using Elsa.Integrations.Telnyx.Contracts; +using Elsa.Integrations.Telnyx.Handlers; +using Elsa.Integrations.Telnyx.Options; +using Elsa.Integrations.Telnyx.Serialization; +using Elsa.Integrations.Telnyx.Services; +using Microsoft.Extensions.Options; +using Refit; + +// ReSharper disable once CheckNamespace +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Provides service dependency extensions that register required services for Telnyx integration. +/// +public static class DependencyInjectionExtensions +{ + /// + /// Adds Telnyx services to the service container. + /// + /// + public static IServiceCollection AddTelnyx( + this IServiceCollection services, + Action? configure = null, + Func? httpClientFactory = null, + Action? configureHttpClientBuilder = null) + { + // Telnyx options. + configure ??= options => options.ApiUrl = new("https://api.telnyx.com"); + services.Configure(configure); + + // Services. + services + .AddNotificationHandlersFrom() + .AddScoped(); + + // Telnyx API Client. + var refitSettings = CreateRefitSettings(); + + services + .AddApiClient(refitSettings, httpClientFactory, configureHttpClientBuilder) + .AddApiClient(refitSettings, httpClientFactory, configureHttpClientBuilder) + .AddTransient(); + + return services; + } + + /// + /// Registers the specified interface type as a Refit client. + /// + private static IServiceCollection AddApiClient( + this IServiceCollection services, + RefitSettings refitSettings, + Func? httpClientFactory, + Action? configureHttpClientBuilder) where T : class + { + if (httpClientFactory == null) + { + var httpClientBuilder = services.AddRefitClient(refitSettings).ConfigureHttpClient((sp, client) => + { + var options = sp.GetRequiredService>().Value; + client.BaseAddress = options.ApiUrl; + client.DefaultRequestHeaders.Authorization = new("Bearer", options.ApiKey); + }); + + configureHttpClientBuilder?.Invoke(httpClientBuilder); + } + else + { + services.AddScoped(sp => + { + var httpClient = httpClientFactory(sp); + var options = sp.GetRequiredService>().Value; + httpClient.BaseAddress ??= options.ApiUrl; + httpClient.DefaultRequestHeaders.Authorization ??= new("Bearer", options.ApiKey); + + return RestService.For(httpClient, refitSettings); + }); + } + + return services; + } + + private static RefitSettings CreateRefitSettings() + { + var serializerSettings = new JsonSerializerOptions() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = new SnakeCaseNamingPolicy(), + }; + + serializerSettings.Converters.Add(new WebhookDataJsonConverter()); + + return new() + { + ContentSerializer = new SystemTextJsonContentSerializer(serializerSettings) + }; + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Extensions/EndpointsExtensions.cs b/src/Elsa.Integrations.Telnyx/Extensions/EndpointsExtensions.cs new file mode 100644 index 00000000..15757e11 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Extensions/EndpointsExtensions.cs @@ -0,0 +1,31 @@ +using Elsa.Integrations.Telnyx.Contracts; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +// ReSharper disable once CheckNamespace +namespace Elsa.Extensions; + +/// +/// Provides extensions on +/// +[PublicAPI] +public static class EndpointsExtensions +{ + /// + /// Maps the specified route to the Telnyx webhook handler. + /// + public static IEndpointConventionBuilder UseTelnyxWebhooks(this IEndpointRouteBuilder endpoints, string routePattern = "telnyx-hook") + { + return endpoints.MapPost(routePattern, HandleTelnyxRequest); + } + + private static async Task HandleTelnyxRequest(HttpContext context) + { + var services = context.RequestServices; + var webhookHandler = services.GetRequiredService(); + await webhookHandler.HandleAsync(context); + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Extensions/ModuleExtensions.cs b/src/Elsa.Integrations.Telnyx/Extensions/ModuleExtensions.cs new file mode 100644 index 00000000..16e8f1d3 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Extensions/ModuleExtensions.cs @@ -0,0 +1,14 @@ +using Elsa.Features.Services; +using Elsa.Integrations.Telnyx.Features; + +// ReSharper disable once CheckNamespace +namespace Elsa.Extensions; + +public static class ModuleExtensions +{ + public static IModule UseTelnyx(this IModule module, Action? configure = null) + { + module.Configure(configure); + return module; + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Extensions/PayloadExtensions.cs b/src/Elsa.Integrations.Telnyx/Extensions/PayloadExtensions.cs new file mode 100644 index 00000000..3dc6a129 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Extensions/PayloadExtensions.cs @@ -0,0 +1,20 @@ +using Elsa.Integrations.Telnyx.Models; +using Elsa.Integrations.Telnyx.Payloads.Abstractions; + +namespace Elsa.Integrations.Telnyx.Extensions; + +/// +/// Provides extensions on . +/// +public static class PayloadExtensions +{ + /// + /// Extracts a from the specified . + /// + public static ClientStatePayload? GetClientStatePayload(this Payload payload) + { + return !string.IsNullOrWhiteSpace(payload.ClientState) + ? ClientStatePayload.FromBase64(payload.ClientState) + : null; + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Extensions/StringExtensions.cs b/src/Elsa.Integrations.Telnyx/Extensions/StringExtensions.cs new file mode 100644 index 00000000..0283e855 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Extensions/StringExtensions.cs @@ -0,0 +1,19 @@ +using System.Text.RegularExpressions; +using Humanizer; + +namespace Elsa.Integrations.Telnyx.Extensions; + +public static class StringExtensions +{ + private static readonly Regex WhiteList = new(@"[^a-zA-Z0-9-_.!~+ ]"); + + /// + /// Sanitizes the caller name. + /// + public static string? SanitizeCallerName(this string? value) => value == null ? null : WhiteList.Replace(value, "").Truncate(128); + + /// + /// Returns null if the specifies string is empty. + /// + public static string? EmptyToNull(this string? value) => value is "" ? null : value; +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Features/TelnyxFeature.cs b/src/Elsa.Integrations.Telnyx/Features/TelnyxFeature.cs new file mode 100644 index 00000000..230792c1 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Features/TelnyxFeature.cs @@ -0,0 +1,69 @@ +using System.ComponentModel; +using System.Reflection; +using Elsa.Extensions; +using Elsa.Features.Abstractions; +using Elsa.Features.Services; +using Elsa.Integrations.Telnyx.Activities; +using Elsa.Integrations.Telnyx.Client.Models; +using Elsa.Integrations.Telnyx.Options; +using Elsa.Integrations.Telnyx.Payloads.Abstractions; +using Elsa.Integrations.Telnyx.Providers; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Integrations.Telnyx.Features; + +/// +/// Enables Telnyx integration. +/// +public class TelnyxFeature : FeatureBase +{ + private const string TelnyxCategoryName = "Telnyx"; + + /// + public TelnyxFeature(IModule module) : base(module) + { + } + + /// + /// Configures Telnyx options. + /// + public Action ConfigureTelnyxOptions { get; set; } = _ => { }; + + /// + /// Gets or sets a factory that creates an used to communicate with the Telnyx API. + /// + public Func? HttpClientFactory { get; set; } + + /// + /// Configures the used to communicate with the Telnyx API. + /// + public Action? ConfigureHttpClientBuilder { get; set; } + + /// + public override void Configure() + { + Module.UseWorkflowManagement(management => + { + management.AddActivitiesFrom(); + + management.AddVariableTypes(typeof(TelnyxFeature).Assembly.ExportedTypes.Where(x => + { + var browsableAttr = x.GetCustomAttribute(); + return typeof(Payload).IsAssignableFrom(x) && browsableAttr == null || browsableAttr?.Browsable == true; + }), TelnyxCategoryName); + + management.AddVariableType(TelnyxCategoryName); + management.AddVariableType(TelnyxCategoryName); + management.AddVariableType(TelnyxCategoryName); + management.AddVariableType(TelnyxCategoryName); + }); + } + + /// + public override void Apply() + { + Services + .AddTelnyx(ConfigureTelnyxOptions, HttpClientFactory, ConfigureHttpClientBuilder) + .AddActivityProvider(); + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/FodyWeavers.xml b/src/Elsa.Integrations.Telnyx/FodyWeavers.xml new file mode 100644 index 00000000..00e1d9a1 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Handlers/TriggerAnswerCallActivities.cs b/src/Elsa.Integrations.Telnyx/Handlers/TriggerAnswerCallActivities.cs new file mode 100644 index 00000000..b5a1300d --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Handlers/TriggerAnswerCallActivities.cs @@ -0,0 +1,56 @@ +using Elsa.Extensions; +using Elsa.Integrations.Telnyx.Activities; +using Elsa.Integrations.Telnyx.Bookmarks; +using Elsa.Integrations.Telnyx.Events; +using Elsa.Integrations.Telnyx.Extensions; +using Elsa.Integrations.Telnyx.Payloads.Call; +using Elsa.Mediator.Contracts; +using Elsa.Workflows.Helpers; +using Elsa.Workflows.Runtime; +using JetBrains.Annotations; +using Microsoft.Extensions.Logging; + +namespace Elsa.Integrations.Telnyx.Handlers; + +/// +/// Triggers all workflows blocked on a or activity. +/// +[PublicAPI] +internal class TriggerAnswerCallActivities(IStimulusSender stimulusSender, ILogger logger) + : INotificationHandler +{ + private readonly ILogger _logger = logger; + + public async Task HandleAsync(TelnyxWebhookReceived notification, CancellationToken cancellationToken) + { + var webhook = notification.Webhook; + var payload = webhook.Data.Payload; + + if (payload is not CallAnsweredPayload callAnsweredPayload) + return; + + var clientStatePayload = callAnsweredPayload.GetClientStatePayload(); + var workflowInstanceId = clientStatePayload?.WorkflowInstanceId; + var activityInstanceId = clientStatePayload?.ActivityInstanceId!; + var input = new Dictionary().AddInput(callAnsweredPayload); + var callControlId = callAnsweredPayload.CallControlId; + + var activityTypeNames = new[] + { + ActivityTypeNameHelper.GenerateTypeName(), + ActivityTypeNameHelper.GenerateTypeName(), + }; + + foreach (var activityTypeName in activityTypeNames) + { + var stimulus = new AnswerCallStimulus(callControlId); + var metadata = new StimulusMetadata + { + WorkflowInstanceId = workflowInstanceId, + ActivityInstanceId = activityInstanceId, + Input = input + }; + await stimulusSender.SendAsync(activityTypeName, stimulus, metadata, cancellationToken); + } + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Handlers/TriggerCallAnsweredActivities.cs b/src/Elsa.Integrations.Telnyx/Handlers/TriggerCallAnsweredActivities.cs new file mode 100644 index 00000000..5ba8b35d --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Handlers/TriggerCallAnsweredActivities.cs @@ -0,0 +1,45 @@ +using Elsa.Extensions; +using Elsa.Integrations.Telnyx.Activities; +using Elsa.Integrations.Telnyx.Bookmarks; +using Elsa.Integrations.Telnyx.Events; +using Elsa.Integrations.Telnyx.Extensions; +using Elsa.Integrations.Telnyx.Payloads.Call; +using Elsa.Mediator.Contracts; +using Elsa.Workflows.Runtime; +using JetBrains.Annotations; +using Microsoft.Extensions.Logging; + +namespace Elsa.Integrations.Telnyx.Handlers; + +/// +/// Triggers all workflows starting with or blocked on a activity. +/// +[PublicAPI] +internal class TriggerCallAnsweredActivities(IStimulusSender stimulusSender, ILogger logger) : INotificationHandler +{ + private readonly ILogger _logger = logger; + + public async Task HandleAsync(TelnyxWebhookReceived notification, CancellationToken cancellationToken) + { + var webhook = notification.Webhook; + var payload = webhook.Data.Payload; + + if (payload is not CallAnsweredPayload callAnsweredPayload) + return; + + var clientStatePayload = callAnsweredPayload.GetClientStatePayload(); + var workflowInstanceId = clientStatePayload?.WorkflowInstanceId; + var activityInstanceId = clientStatePayload?.ActivityInstanceId!; + var input = new Dictionary().AddInput(callAnsweredPayload); + var callControlId = callAnsweredPayload.CallControlId; + + var stimulus = new CallAnsweredStimulus(callControlId); + var metadata = new StimulusMetadata + { + WorkflowInstanceId = workflowInstanceId, + ActivityInstanceId = activityInstanceId, + Input = input + }; + await stimulusSender.SendAsync(stimulus, metadata, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Handlers/TriggerCallBridgedActivities.cs b/src/Elsa.Integrations.Telnyx/Handlers/TriggerCallBridgedActivities.cs new file mode 100644 index 00000000..86e5cd46 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Handlers/TriggerCallBridgedActivities.cs @@ -0,0 +1,49 @@ +using Elsa.Extensions; +using Elsa.Integrations.Telnyx.Activities; +using Elsa.Integrations.Telnyx.Bookmarks; +using Elsa.Integrations.Telnyx.Events; +using Elsa.Integrations.Telnyx.Extensions; +using Elsa.Integrations.Telnyx.Payloads.Call; +using Elsa.Mediator.Contracts; +using Elsa.Workflows.Helpers; +using Elsa.Workflows.Runtime; +using JetBrains.Annotations; + +namespace Elsa.Integrations.Telnyx.Handlers; + +/// +/// Triggers all workflows starting with or blocked on a activity. +/// +[PublicAPI] +internal class TriggerCallBridgedActivities(IStimulusSender stimulusSender) : INotificationHandler +{ + public async Task HandleAsync(TelnyxWebhookReceived notification, CancellationToken cancellationToken) + { + var webhook = notification.Webhook; + var payload = webhook.Data.Payload; + + if (payload is not CallBridgedPayload callBridgedPayload) + return; + + var clientStatePayload = callBridgedPayload.GetClientStatePayload(); + var workflowInstanceId = clientStatePayload?.WorkflowInstanceId; + var input = new Dictionary().AddInput(callBridgedPayload); + var callControlId = callBridgedPayload.CallControlId; + var activityTypeNames = new[] + { + ActivityTypeNameHelper.GenerateTypeName(), + ActivityTypeNameHelper.GenerateTypeName(), + }; + + foreach (var activityTypeName in activityTypeNames) + { + var stimulus = new WebhookEventStimulus(WebhookEventTypes.CallBridged, callControlId); + var metadata = new StimulusMetadata + { + WorkflowInstanceId = workflowInstanceId, + Input = input + }; + await stimulusSender.SendAsync(activityTypeName, stimulus, metadata, cancellationToken); + } + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Handlers/TriggerCallHangupActivities.cs b/src/Elsa.Integrations.Telnyx/Handlers/TriggerCallHangupActivities.cs new file mode 100644 index 00000000..1d14570d --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Handlers/TriggerCallHangupActivities.cs @@ -0,0 +1,40 @@ +using Elsa.Extensions; +using Elsa.Integrations.Telnyx.Activities; +using Elsa.Integrations.Telnyx.Bookmarks; +using Elsa.Integrations.Telnyx.Events; +using Elsa.Integrations.Telnyx.Extensions; +using Elsa.Integrations.Telnyx.Payloads.Call; +using Elsa.Mediator.Contracts; +using Elsa.Workflows.Runtime; +using JetBrains.Annotations; + +namespace Elsa.Integrations.Telnyx.Handlers; + +/// +/// Triggers all workflows starting with or blocked on a activity. +/// +[PublicAPI] +internal class TriggerCallHangupActivities(IStimulusSender stimulusSender) + : INotificationHandler +{ + public async Task HandleAsync(TelnyxWebhookReceived notification, CancellationToken cancellationToken) + { + var webhook = notification.Webhook; + var payload = webhook.Data.Payload; + + if (payload is not CallHangupPayload callHangupPayload) + return; + + var clientStatePayload = callHangupPayload.GetClientStatePayload(); + var workflowInstanceId = clientStatePayload?.WorkflowInstanceId; + var input = new Dictionary().AddInput(callHangupPayload); + var callControlId = callHangupPayload.CallControlId; + var stimulus = new CallHangupStimulus(callControlId); + var metadata = new StimulusMetadata + { + WorkflowInstanceId = workflowInstanceId, + Input = input + }; + await stimulusSender.SendAsync(stimulus, metadata, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Handlers/TriggerIncomingCallActivities.cs b/src/Elsa.Integrations.Telnyx/Handlers/TriggerIncomingCallActivities.cs new file mode 100644 index 00000000..061e2ff4 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Handlers/TriggerIncomingCallActivities.cs @@ -0,0 +1,55 @@ +using Elsa.Extensions; +using Elsa.Integrations.Telnyx.Activities; +using Elsa.Integrations.Telnyx.Bookmarks; +using Elsa.Integrations.Telnyx.Events; +using Elsa.Integrations.Telnyx.Payloads.Call; +using Elsa.Mediator.Contracts; +using Elsa.Workflows.Runtime; +using JetBrains.Annotations; + +namespace Elsa.Integrations.Telnyx.Handlers; + +/// +/// Triggers all workflows starting with or blocked on a activity. +/// +[PublicAPI] +internal class TriggerIncomingCallActivities(IStimulusSender stimulusSender) + : INotificationHandler +{ + public async Task HandleAsync(TelnyxWebhookReceived notification, CancellationToken cancellationToken) + { + var webhook = notification.Webhook; + var payload = webhook.Data.Payload; + + if (payload is not CallInitiatedPayload callInitiatedPayload) + return; + + // Only trigger workflows for incoming calls. + if (callInitiatedPayload.Direction != "incoming" || callInitiatedPayload.ClientState != null) + return; + + var input = new Dictionary().AddInput(webhook); + var stimulusMetadata = new StimulusMetadata + { + Input = input + }; + + // Trigger all workflows matching the From number. + var fromNumber = callInitiatedPayload.From; + var fromStimulus = new IncomingCallFromStimulus(fromNumber); + var fromResults = await stimulusSender.SendAsync(fromStimulus, stimulusMetadata, cancellationToken); + + // Trigger all workflows matching the To number. + var toNumber = callInitiatedPayload.To; + var toStimulus = new IncomingCallToStimulus(toNumber); + var toResults = await stimulusSender.SendAsync(toStimulus, stimulusMetadata, cancellationToken); + + // If any workflows were triggered, don't trigger the catch-all workflows. + if (fromResults.WorkflowInstanceResponses.Any() || toResults.WorkflowInstanceResponses.Any()) + return; + + // Trigger all catch-all workflows. + var catchAllStimulus = new IncomingCallCatchAllStimulus(); + await stimulusSender.SendAsync(catchAllStimulus, stimulusMetadata, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Handlers/TriggerWebhookActivities.cs b/src/Elsa.Integrations.Telnyx/Handlers/TriggerWebhookActivities.cs new file mode 100644 index 00000000..6cbe1345 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Handlers/TriggerWebhookActivities.cs @@ -0,0 +1,44 @@ +using System.Reflection; +using Elsa.Extensions; +using Elsa.Integrations.Telnyx.Activities; +using Elsa.Integrations.Telnyx.Attributes; +using Elsa.Integrations.Telnyx.Bookmarks; +using Elsa.Integrations.Telnyx.Events; +using Elsa.Integrations.Telnyx.Extensions; +using Elsa.Integrations.Telnyx.Payloads.Abstractions; +using Elsa.Mediator.Contracts; +using Elsa.Workflows.Runtime; +using JetBrains.Annotations; + +namespace Elsa.Integrations.Telnyx.Handlers; + +/// +/// Triggers all workflows starting with or blocked on a activity. +/// +[PublicAPI] +internal class TriggerWebhookActivities(IStimulusSender stimulusSender) : INotificationHandler +{ + public async Task HandleAsync(TelnyxWebhookReceived notification, CancellationToken cancellationToken) + { + var webhook = notification.Webhook; + var eventType = webhook.Data.EventType; + var payload = webhook.Data.Payload; + var activityType = payload.GetType().GetCustomAttribute()?.ActivityType; + + if (activityType == null) + return; + + var workflowInstanceId = ((Payload)webhook.Data.Payload).GetClientStatePayload()?.WorkflowInstanceId; + var callControlId = (webhook.Data.Payload as CallPayload)?.CallControlId; + var stimulus = new WebhookEventStimulus(eventType, callControlId); + var input = new Dictionary().AddInput(webhook); + + var metadata = new StimulusMetadata + { + Input = input, + WorkflowInstanceId = workflowInstanceId, + + }; + await stimulusSender.SendAsync(activityType, stimulus, metadata, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Handlers/TriggerWebhookDrivenActivities.cs b/src/Elsa.Integrations.Telnyx/Handlers/TriggerWebhookDrivenActivities.cs new file mode 100644 index 00000000..4ea9ba79 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Handlers/TriggerWebhookDrivenActivities.cs @@ -0,0 +1,51 @@ +using Elsa.Extensions; +using Elsa.Integrations.Telnyx.Attributes; +using Elsa.Integrations.Telnyx.Bookmarks; +using Elsa.Integrations.Telnyx.Events; +using Elsa.Integrations.Telnyx.Extensions; +using Elsa.Integrations.Telnyx.Payloads.Abstractions; +using Elsa.Mediator.Contracts; +using Elsa.Workflows; +using Elsa.Workflows.Models; +using Elsa.Workflows.Runtime; +using JetBrains.Annotations; + +namespace Elsa.Integrations.Telnyx.Handlers; + +/// +/// Resumes all workflows blocked on activities that are waiting for a given webhook. +/// +[PublicAPI] +internal class TriggerWebhookDrivenActivities(IStimulusSender stimulusSender, IActivityRegistry activityRegistry) + : INotificationHandler +{ + public async Task HandleAsync(TelnyxWebhookReceived notification, CancellationToken cancellationToken) + { + var webhook = notification.Webhook; + var eventType = webhook.Data.EventType; + var eventPayload = webhook.Data.Payload; + var callPayload = eventPayload as CallPayload; + var callControlId = callPayload?.CallControlId; + var input = new Dictionary().AddInput(eventPayload.GetType().Name, eventPayload); + var activityDescriptors = FindActivityDescriptors(eventType).ToList(); + var clientStatePayload = ((Payload)webhook.Data.Payload).GetClientStatePayload(); + var activityInstanceId = clientStatePayload?.ActivityInstanceId; + var workflowInstanceId = clientStatePayload?.WorkflowInstanceId; + var bookmarkPayloadWithCallControl = new WebhookEventStimulus(eventType, callControlId); + + foreach (var activityDescriptor in activityDescriptors) + { + var metadata = new StimulusMetadata + { + WorkflowInstanceId = workflowInstanceId, + ActivityInstanceId = activityInstanceId, + Input = input + }; + + await stimulusSender.SendAsync(activityDescriptor.TypeName, bookmarkPayloadWithCallControl, metadata, cancellationToken); + } + } + + private IEnumerable FindActivityDescriptors(string eventType) => + activityRegistry.FindMany(descriptor => descriptor.GetAttribute()?.EventTypes.Contains(eventType) == true); +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Helpers/PayloadSerializer.cs b/src/Elsa.Integrations.Telnyx/Helpers/PayloadSerializer.cs new file mode 100644 index 00000000..01e1269b --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Helpers/PayloadSerializer.cs @@ -0,0 +1,14 @@ +using System.Text.Json; +using Elsa.Integrations.Telnyx.Payloads; +using Elsa.Integrations.Telnyx.Payloads.Abstractions; + +namespace Elsa.Integrations.Telnyx.Helpers; + +internal static class PayloadSerializer +{ + public static Payload Deserialize(string eventType, JsonElement dataModel, JsonSerializerOptions? options = null) + { + var payloadType = WebhookPayloadTypes.PayloadTypeDictionary.TryGetValue(eventType, out var value) ? value : typeof(UnsupportedPayload); + return (Payload)dataModel.Deserialize(payloadType, options)!; + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Helpers/WebhookPayloadTypes.cs b/src/Elsa.Integrations.Telnyx/Helpers/WebhookPayloadTypes.cs new file mode 100644 index 00000000..2cc25827 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Helpers/WebhookPayloadTypes.cs @@ -0,0 +1,34 @@ +using System.Reflection; +using Elsa.Integrations.Telnyx.Attributes; +using Elsa.Integrations.Telnyx.Payloads.Abstractions; + +namespace Elsa.Integrations.Telnyx.Helpers; + +/// +/// A helper class related to webhook event payload discovery. +/// +public static class WebhookPayloadTypes +{ + /// + /// A list of types. + /// + public static readonly ICollection PayloadTypes; + + /// + /// A dictionary of event types mapped to payload types. + /// + public static readonly IDictionary PayloadTypeDictionary; + + static WebhookPayloadTypes() + { + PayloadTypes = typeof(WebhookPayloadTypes).Assembly.GetTypes().Where(x => typeof(Payload).IsAssignableFrom(x) && x.GetCustomAttribute(true) != null).ToList(); + + var query = + from payloadType in PayloadTypes + let payloadAttribute = payloadType.GetCustomAttribute() + where payloadAttribute != null + select (payloadType, payloadAttribute); + + PayloadTypeDictionary = query.ToDictionary(x => x.payloadAttribute!.EventType, x => x.payloadType); + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Helpers/WebhookSerializerOptions.cs b/src/Elsa.Integrations.Telnyx/Helpers/WebhookSerializerOptions.cs new file mode 100644 index 00000000..17a1ce6d --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Helpers/WebhookSerializerOptions.cs @@ -0,0 +1,19 @@ +using System.Text.Json; +using Elsa.Integrations.Telnyx.Serialization; + +namespace Elsa.Integrations.Telnyx.Helpers; + +internal static class WebhookSerializerOptions +{ + public static JsonSerializerOptions Create() + { + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = new SnakeCaseNamingPolicy() + }; + + options.Converters.Add(new WebhookDataJsonConverter()); + return options; + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Models/BridgedCallsOutput.cs b/src/Elsa.Integrations.Telnyx/Models/BridgedCallsOutput.cs new file mode 100644 index 00000000..ba3b07a6 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Models/BridgedCallsOutput.cs @@ -0,0 +1,11 @@ +using Elsa.Integrations.Telnyx.Activities; +using Elsa.Integrations.Telnyx.Payloads.Call; + +namespace Elsa.Integrations.Telnyx.Models; + +/// +/// Contains output of the activity. +/// +/// The payload from leg A. +/// The payload from leg B. +public record BridgedCallsOutput(CallBridgedPayload PayloadA, CallBridgedPayload PayloadB); \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Models/CallRecordingUrls.cs b/src/Elsa.Integrations.Telnyx/Models/CallRecordingUrls.cs new file mode 100644 index 00000000..888804c8 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Models/CallRecordingUrls.cs @@ -0,0 +1,3 @@ +namespace Elsa.Integrations.Telnyx.Models; + +public record CallRecordingUrls(string? Wav, string? Mp3); \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Models/ClientStatePayload.cs b/src/Elsa.Integrations.Telnyx/Models/ClientStatePayload.cs new file mode 100644 index 00000000..003c7047 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Models/ClientStatePayload.cs @@ -0,0 +1,33 @@ +using System.Text; +using System.Text.Json; + +namespace Elsa.Integrations.Telnyx.Models; + +/// +/// Represents the client state payload to correlate commands and events. +/// +/// The correlation ID. +/// An optional activity instance ID. +public record ClientStatePayload(string WorkflowInstanceId, string? ActivityInstanceId = null) +{ + /// + /// Deserializes a from the specified base64 string. + /// + public static ClientStatePayload FromBase64(string base64) + { + var bytes = Convert.FromBase64String(base64); + var json = Encoding.UTF8.GetString(bytes); + return JsonSerializer.Deserialize(json)!; + } + + /// + /// Serializes the to a base64 string. + /// + /// + public string ToBase64() + { + var json = JsonSerializer.Serialize(this); + var bytes = Encoding.UTF8.GetBytes(json); + return Convert.ToBase64String(bytes); + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Models/TelnyxRecord.cs b/src/Elsa.Integrations.Telnyx/Models/TelnyxRecord.cs new file mode 100644 index 00000000..2f3e7186 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Models/TelnyxRecord.cs @@ -0,0 +1,3 @@ +namespace Elsa.Integrations.Telnyx.Models; + +public abstract record TelnyxRecord(string RecordType); \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Models/TelnyxWebhook.cs b/src/Elsa.Integrations.Telnyx/Models/TelnyxWebhook.cs new file mode 100644 index 00000000..324f44c7 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Models/TelnyxWebhook.cs @@ -0,0 +1,8 @@ +namespace Elsa.Integrations.Telnyx.Models; + +[Serializable] +public class TelnyxWebhook +{ + public TelnyxWebhookMeta Meta { get; set; } = null!; + public TelnyxWebhookData Data { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Models/TelnyxWebhookData.cs b/src/Elsa.Integrations.Telnyx/Models/TelnyxWebhookData.cs new file mode 100644 index 00000000..4afd4e4e --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Models/TelnyxWebhookData.cs @@ -0,0 +1,6 @@ +namespace Elsa.Integrations.Telnyx.Models; + +// TODO: The Payload property is of type object instead of Payload, this in order for the JSON serializer to serialize derived type properties. +// Once moved to .NET 7, we have more control over polymorphism. +// https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/polymorphism?pivots=dotnet-7-0 +public record TelnyxWebhookData(string EventType, Guid Id, DateTimeOffset OccurredAt, string RecordType, object Payload) : TelnyxRecord(EventType); \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Models/TelnyxWebhookMeta.cs b/src/Elsa.Integrations.Telnyx/Models/TelnyxWebhookMeta.cs new file mode 100644 index 00000000..697c876d --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Models/TelnyxWebhookMeta.cs @@ -0,0 +1,7 @@ +namespace Elsa.Integrations.Telnyx.Models; + +public class TelnyxWebhookMeta +{ + public int Attempt { get; set; } + public string DeliveredTo { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Options/TelnyxOptions.cs b/src/Elsa.Integrations.Telnyx/Options/TelnyxOptions.cs new file mode 100644 index 00000000..cac37e03 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Options/TelnyxOptions.cs @@ -0,0 +1,8 @@ +namespace Elsa.Integrations.Telnyx.Options; + +public class TelnyxOptions +{ + public Uri ApiUrl { get; set; } = new("https://api.telnyx.com"); + public string ApiKey { get; set; } = null!; + public string? CallControlAppId { get; set; } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Payloads/Abstractions/CallPayload.cs b/src/Elsa.Integrations.Telnyx/Payloads/Abstractions/CallPayload.cs new file mode 100644 index 00000000..58b46e46 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Payloads/Abstractions/CallPayload.cs @@ -0,0 +1,12 @@ +namespace Elsa.Integrations.Telnyx.Payloads.Abstractions; + +/// +/// A base class for payloads that are related to a call. +/// +public abstract record CallPayload : Payload +{ + public string CallControlId { get; init; } = null!; + public string CallLegId { get; init; } = null!; + public string CallSessionId { get; init; } = null!; + public string ConnectionId { get; init; } = null!; +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Payloads/Abstractions/CallPlayback.cs b/src/Elsa.Integrations.Telnyx/Payloads/Abstractions/CallPlayback.cs new file mode 100644 index 00000000..0a7d5593 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Payloads/Abstractions/CallPlayback.cs @@ -0,0 +1,7 @@ +namespace Elsa.Integrations.Telnyx.Payloads.Abstractions; + +public abstract record CallPlayback : CallPayload +{ + public Uri MediaUrl { get; init; } = null!; + public bool Overlay { get; set; } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Payloads/Abstractions/Payload.cs b/src/Elsa.Integrations.Telnyx/Payloads/Abstractions/Payload.cs new file mode 100644 index 00000000..541750aa --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Payloads/Abstractions/Payload.cs @@ -0,0 +1,6 @@ +namespace Elsa.Integrations.Telnyx.Payloads.Abstractions; + +public abstract record Payload +{ + public string? ClientState { get; set; } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Payloads/Call/CallAnsweredPayload.cs b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallAnsweredPayload.cs new file mode 100644 index 00000000..d00fe073 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallAnsweredPayload.cs @@ -0,0 +1,26 @@ +using Elsa.Integrations.Telnyx.Attributes; +using Elsa.Integrations.Telnyx.Payloads.Abstractions; + +namespace Elsa.Integrations.Telnyx.Payloads.Call; + +/// +/// Represents the payload of a webhook event that is triggered when an incoming call is answered. +/// +[Webhook(WebhookEventTypes.CallAnswered)] +public sealed record CallAnsweredPayload : CallPayload +{ + /// + /// The from number. + /// + public string From { get; init; } = null!; + + /// + /// The to number. + /// + public string To { get; init; } = null!; + + /// + /// Call state. + /// + public string State { get; init; } = null!; +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Payloads/Call/CallBridgedPayload.cs b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallBridgedPayload.cs new file mode 100644 index 00000000..4b3c92dd --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallBridgedPayload.cs @@ -0,0 +1,12 @@ +using Elsa.Integrations.Telnyx.Attributes; +using Elsa.Integrations.Telnyx.Payloads.Abstractions; + +namespace Elsa.Integrations.Telnyx.Payloads.Call; + +[WebhookActivity(WebhookEventTypes.CallBridged, WebhookActivityTypeNames.CallBridged, "Call Bridged", "Triggered when an a call is bridged.")] +public sealed record CallBridgedPayload : CallPayload +{ + public string From { get; init; } = null!; + public string To { get; init; } = null!; + public string State { get; init; } = null!; +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Payloads/Call/CallDtmfReceivedPayload.cs b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallDtmfReceivedPayload.cs new file mode 100644 index 00000000..1689792a --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallDtmfReceivedPayload.cs @@ -0,0 +1,12 @@ +using Elsa.Integrations.Telnyx.Attributes; +using Elsa.Integrations.Telnyx.Payloads.Abstractions; + +namespace Elsa.Integrations.Telnyx.Payloads.Call; + +[WebhookActivity(WebhookEventTypes.CallDtmfReceived, WebhookActivityTypeNames.CallDtmfReceived, "Call DTMF Received", "Triggered when DTMF input is received.")] +public sealed record CallDtmfReceivedPayload : CallPayload +{ + public string Digit { get; set; } = null!; + public string From { get; set; } = null!; + public string To { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Payloads/Call/CallGatherEndedPayload.cs b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallGatherEndedPayload.cs new file mode 100644 index 00000000..c46bb97d --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallGatherEndedPayload.cs @@ -0,0 +1,13 @@ +using Elsa.Integrations.Telnyx.Attributes; +using Elsa.Integrations.Telnyx.Payloads.Abstractions; + +namespace Elsa.Integrations.Telnyx.Payloads.Call; + +[WebhookActivity(WebhookEventTypes.CallGatherEnded, WebhookActivityTypeNames.CallGatherEnded, "Call Gather Ended", "Triggered when an call gather has ended.")] +public sealed record CallGatherEndedPayload : CallPayload +{ + public string Digits { get; set; } = null!; + public string From { get; set; } = null!; + public string To { get; set; } = null!; + public string Status { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Payloads/Call/CallHangupPayload.cs b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallHangupPayload.cs new file mode 100644 index 00000000..2993d9f7 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallHangupPayload.cs @@ -0,0 +1,19 @@ +using Elsa.Integrations.Telnyx.Attributes; +using Elsa.Integrations.Telnyx.Payloads.Abstractions; + +namespace Elsa.Integrations.Telnyx.Payloads.Call; + +/// +/// A payload representing the call.hangup Telnyx webhook event. +/// +[Webhook(WebhookEventTypes.CallHangup)] +public sealed record CallHangupPayload : CallPayload +{ + public DateTimeOffset StartTime { get; init; } + public DateTimeOffset EndTime { get; init; } + public string SipHangupCause { get; init; } = null!; + public string HangupSource { get; init; } = null!; + public string HangupCause { get; init; } = null!; + public string From { get; set; } = null!; + public string To { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Payloads/Call/CallInitiatedPayload.cs b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallInitiatedPayload.cs new file mode 100644 index 00000000..4dfa6a78 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallInitiatedPayload.cs @@ -0,0 +1,14 @@ +using Elsa.Integrations.Telnyx.Attributes; +using Elsa.Integrations.Telnyx.Payloads.Abstractions; + +namespace Elsa.Integrations.Telnyx.Payloads.Call; + +[Webhook(WebhookEventTypes.CallInitiated)] +public sealed record CallInitiatedPayload : CallPayload +{ + public string Direction { get; init; } = null!; + public string State { get; init; } = null!; + public string To { get; init; } = null!; + public string From { get; init; } = null!; + public DateTimeOffset StartTime { get; set; } = default!; +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Payloads/Call/CallMachineDetectionEndedBase.cs b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallMachineDetectionEndedBase.cs new file mode 100644 index 00000000..28fe1f68 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallMachineDetectionEndedBase.cs @@ -0,0 +1,8 @@ +using Elsa.Integrations.Telnyx.Payloads.Abstractions; + +namespace Elsa.Integrations.Telnyx.Payloads.Call; + +public record CallMachineDetectionEndedBase : CallPayload +{ + public string Result { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Payloads/Call/CallMachineGreetingEnded.cs b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallMachineGreetingEnded.cs new file mode 100644 index 00000000..bedb3006 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallMachineGreetingEnded.cs @@ -0,0 +1,6 @@ +using Elsa.Integrations.Telnyx.Attributes; + +namespace Elsa.Integrations.Telnyx.Payloads.Call; + +[WebhookActivity(WebhookEventTypes.CallMachineGreetingEnded, WebhookActivityTypeNames.CallMachineGreetingEnded, "Call Machine Greeting Ended", "Triggered when a machine greeting has ended.")] +public sealed record CallMachineGreetingEnded : CallMachineGreetingEndedBase; \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Payloads/Call/CallMachineGreetingEndedBase.cs b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallMachineGreetingEndedBase.cs new file mode 100644 index 00000000..fbf22812 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallMachineGreetingEndedBase.cs @@ -0,0 +1,8 @@ +using Elsa.Integrations.Telnyx.Payloads.Abstractions; + +namespace Elsa.Integrations.Telnyx.Payloads.Call; + +public record CallMachineGreetingEndedBase : CallPayload +{ + public string Result { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Payloads/Call/CallMachinePremiumDetectionEnded.cs b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallMachinePremiumDetectionEnded.cs new file mode 100644 index 00000000..cd091d42 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallMachinePremiumDetectionEnded.cs @@ -0,0 +1,13 @@ +using Elsa.Integrations.Telnyx.Attributes; + +namespace Elsa.Integrations.Telnyx.Payloads.Call; + +[WebhookActivity( + WebhookEventTypes.CallMachinePremiumDetectionEnded, + WebhookActivityTypeNames.CallMachinePremiumDetectionEnded, + "Call Machine Premium Detection Ended", + "Triggered when machine detection has ended." +)] +public sealed record CallMachinePremiumDetectionEnded : CallMachineDetectionEndedBase +{ +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Payloads/Call/CallMachinePremiumGreetingEnded.cs b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallMachinePremiumGreetingEnded.cs new file mode 100644 index 00000000..650c3049 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallMachinePremiumGreetingEnded.cs @@ -0,0 +1,11 @@ +using Elsa.Integrations.Telnyx.Attributes; + +namespace Elsa.Integrations.Telnyx.Payloads.Call; + +[WebhookActivity( + WebhookEventTypes.CallMachinePremiumGreetingEnded, + WebhookActivityTypeNames.CallMachinePremiumGreetingEnded, + "Call Machine Premium Greeting Ended", + "Triggered when a machine greeting has ended." +)] +public sealed record CallMachinePremiumGreetingEnded : CallMachineGreetingEndedBase; \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Payloads/Call/CallPlaybackEndedPayload.cs b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallPlaybackEndedPayload.cs new file mode 100644 index 00000000..7ff09a22 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallPlaybackEndedPayload.cs @@ -0,0 +1,10 @@ +using Elsa.Integrations.Telnyx.Attributes; +using Elsa.Integrations.Telnyx.Payloads.Abstractions; + +namespace Elsa.Integrations.Telnyx.Payloads.Call; + +[WebhookActivity(WebhookEventTypes.CallPlaybackEnded, WebhookActivityTypeNames.CallPlaybackEnded, "Call Playback Ended", "Triggered when an audio playback has ended.")] +public sealed record CallPlaybackEndedPayload : CallPlayback +{ + public string Status { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Payloads/Call/CallPlaybackStartedPayload.cs b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallPlaybackStartedPayload.cs new file mode 100644 index 00000000..9d2a9f44 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallPlaybackStartedPayload.cs @@ -0,0 +1,7 @@ +using Elsa.Integrations.Telnyx.Attributes; +using Elsa.Integrations.Telnyx.Payloads.Abstractions; + +namespace Elsa.Integrations.Telnyx.Payloads.Call; + +[WebhookActivity(WebhookEventTypes.CallPlaybackStarted, WebhookActivityTypeNames.CallPlaybackStarted, "Call Playback Started", "Triggered when an audio playback has started.")] +public sealed record CallPlaybackStartedPayload : CallPlayback; \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Payloads/Call/CallRecordingSavedPayload.cs b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallRecordingSavedPayload.cs new file mode 100644 index 00000000..2a531d14 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallRecordingSavedPayload.cs @@ -0,0 +1,16 @@ +using Elsa.Integrations.Telnyx.Attributes; +using Elsa.Integrations.Telnyx.Models; +using Elsa.Integrations.Telnyx.Payloads.Abstractions; + +namespace Elsa.Integrations.Telnyx.Payloads.Call; + +[WebhookActivity(WebhookEventTypes.CallRecordingSaved, WebhookActivityTypeNames.CallRecordingSaved, "Call Recording Saved", "Triggered when a recording has been saved.")] +public sealed record CallRecordingSavedPayload : CallPayload +{ + public string Channels { get; set; } = null!; + public CallRecordingUrls PublicRecordingUrls { get; set; } = null!; + public CallRecordingUrls RecordingUrls { get; set; } = null!; + public DateTimeOffset RecordingEndedAt { get; set; } + public DateTimeOffset RecordingStartedAt { get; set; } + public TimeSpan Duration => RecordingEndedAt - RecordingStartedAt; +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Payloads/Call/CallSpeakEnded.cs b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallSpeakEnded.cs new file mode 100644 index 00000000..7da4e519 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallSpeakEnded.cs @@ -0,0 +1,7 @@ +using Elsa.Integrations.Telnyx.Attributes; +using Elsa.Integrations.Telnyx.Payloads.Abstractions; + +namespace Elsa.Integrations.Telnyx.Payloads.Call; + +[Webhook(WebhookEventTypes.CallSpeakEnded)] +public sealed record CallSpeakEnded : CallPayload; \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Payloads/Call/CallSpeakStarted.cs b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallSpeakStarted.cs new file mode 100644 index 00000000..ae5cfa6e --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Payloads/Call/CallSpeakStarted.cs @@ -0,0 +1,7 @@ +using Elsa.Integrations.Telnyx.Attributes; +using Elsa.Integrations.Telnyx.Payloads.Abstractions; + +namespace Elsa.Integrations.Telnyx.Payloads.Call; + +[Webhook(WebhookEventTypes.CallSpeakStarted)] +public sealed record CallSpeakStarted : CallPayload; \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Payloads/UnsupportedPayload.cs b/src/Elsa.Integrations.Telnyx/Payloads/UnsupportedPayload.cs new file mode 100644 index 00000000..8d35cea4 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Payloads/UnsupportedPayload.cs @@ -0,0 +1,9 @@ +using System.ComponentModel; +using Elsa.Integrations.Telnyx.Payloads.Abstractions; + +namespace Elsa.Integrations.Telnyx.Payloads; + +[Browsable(false)] +public sealed record UnsupportedPayload : Payload +{ +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Providers/WebhookEventActivityProvider.cs b/src/Elsa.Integrations.Telnyx/Providers/WebhookEventActivityProvider.cs new file mode 100644 index 00000000..ba4f0f9a --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Providers/WebhookEventActivityProvider.cs @@ -0,0 +1,67 @@ +using System.ComponentModel; +using System.Reflection; +using Elsa.Extensions; +using Elsa.Integrations.Telnyx.Activities; +using Elsa.Integrations.Telnyx.Attributes; +using Elsa.Integrations.Telnyx.Helpers; +using Elsa.Integrations.Telnyx.Payloads.Abstractions; +using Elsa.Workflows; +using Elsa.Workflows.Management; +using Elsa.Workflows.Models; + +namespace Elsa.Integrations.Telnyx.Providers; + +/// +/// Provides activity descriptors based on Telnyx webhook event payload types (types inheriting . +/// +public class WebhookEventActivityProvider(IActivityFactory activityFactory, IActivityDescriber activityDescriber) : IActivityProvider +{ + /// + public async ValueTask> GetDescriptorsAsync(CancellationToken cancellationToken = default) + { + var payloadTypes = WebhookPayloadTypes.PayloadTypes.Where(x => x.GetCustomAttribute() != null); + return await CreateDescriptorsAsync(payloadTypes, cancellationToken); + } + + private async Task> CreateDescriptorsAsync(IEnumerable payloadTypes, CancellationToken cancellationToken = default) + { + return await Task.WhenAll(payloadTypes.Select(async x => await CreateDescriptorAsync(x, cancellationToken))); + } + + private async Task CreateDescriptorAsync(Type payloadType, CancellationToken cancellationToken = default) + { + var webhookAttribute = payloadType.GetCustomAttribute() ?? throw new($"No WebhookActivityAttribute found on payload type {payloadType}"); + var typeName = webhookAttribute.ActivityType; + var displayNameAttr = payloadType.GetCustomAttribute(); + var displayName = displayNameAttr?.DisplayName ?? webhookAttribute.DisplayName; + var categoryAttr = payloadType.GetCustomAttribute(); + var category = categoryAttr?.Category ?? Constants.Category; + var descriptionAttr = payloadType.GetCustomAttribute(); + var description = descriptionAttr?.Description ?? webhookAttribute.Description; + var outputPropertyDescriptor = await activityDescriber.DescribeOutputProperty>(x => x.Result!, cancellationToken); + + outputPropertyDescriptor.Type = payloadType; + + return new() + { + TypeName = typeName, + Name = typeName, + Version = 1, + DisplayName = displayName, + Description = description, + Category = category, + Kind = ActivityKind.Trigger, + IsBrowsable = true, + Attributes = { webhookAttribute! }, + Outputs = { outputPropertyDescriptor }, + Constructor = context => + { + var activity = activityFactory.Create(context); + activity.Type = typeName; + activity.EventType = webhookAttribute!.EventType; + + return activity; + } + }; + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Serialization/SnakeCaseNamingPolicy.cs b/src/Elsa.Integrations.Telnyx/Serialization/SnakeCaseNamingPolicy.cs new file mode 100644 index 00000000..092071f2 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Serialization/SnakeCaseNamingPolicy.cs @@ -0,0 +1,13 @@ +using System.Text.Json; +using Humanizer; + +namespace Elsa.Integrations.Telnyx.Serialization; + +/// +/// Reads and writes names using snake_case casing. +/// +public sealed class SnakeCaseNamingPolicy : JsonNamingPolicy +{ + /// + public override string ConvertName(string name) => name.Underscore(); +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Serialization/WebhookDataJsonConverter.cs b/src/Elsa.Integrations.Telnyx/Serialization/WebhookDataJsonConverter.cs new file mode 100644 index 00000000..fc57e45e --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Serialization/WebhookDataJsonConverter.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Elsa.Extensions; +using Elsa.Integrations.Telnyx.Helpers; +using Elsa.Integrations.Telnyx.Models; +using Elsa.Integrations.Telnyx.Payloads.Abstractions; + +namespace Elsa.Integrations.Telnyx.Serialization; + +/// +/// Converts json payloads received from Telnyx into concrete objects. +/// +public class WebhookDataJsonConverter : JsonConverter +{ + /// + public override TelnyxWebhookData Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var dataModel = JsonElement.ParseValue(ref reader); + var eventType = dataModel.GetProperty("event_type", "EventType").GetString()!; + var id = dataModel.GetProperty("id", "Id").GetGuid(); + var occurredAt = dataModel.GetProperty("occurred_at", "OccurredAt").GetDateTimeOffset(); + var recordType = dataModel.GetProperty("record_type", "RecordType").GetString()!; + var payload = PayloadSerializer.Deserialize(eventType, dataModel.GetProperty("payload", "Payload"), options); + + return new(eventType, id, occurredAt, recordType, payload); + } + + /// + public override void Write(Utf8JsonWriter writer, TelnyxWebhookData value, JsonSerializerOptions options) + { + var localOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = new SnakeCaseNamingPolicy() + }; + + var model = JsonSerializer.SerializeToElement(value); + JsonSerializer.Serialize(writer, model, localOptions); + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/Services/WebhookHandler.cs b/src/Elsa.Integrations.Telnyx/Services/WebhookHandler.cs new file mode 100644 index 00000000..1d9cfc2c --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/Services/WebhookHandler.cs @@ -0,0 +1,60 @@ +using System.Text; +using System.Text.Json; +using Elsa.Integrations.Telnyx.Contracts; +using Elsa.Integrations.Telnyx.Events; +using Elsa.Integrations.Telnyx.Models; +using Elsa.Integrations.Telnyx.Serialization; +using Elsa.Mediator; +using Elsa.Mediator.Contracts; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Elsa.Integrations.Telnyx.Services; + +internal class WebhookHandler(INotificationSender notificationSender, ILogger logger) : IWebhookHandler +{ + private static readonly JsonSerializerOptions SerializerSettings; + + static WebhookHandler() + { + SerializerSettings = CreateSerializerSettings(); + } + + public async Task HandleAsync(HttpContext httpContext) + { + var cancellationToken = httpContext.RequestAborted; + var json = await ReadRequestBodyAsync(httpContext); + var webhook = JsonSerializer.Deserialize(json, SerializerSettings)!; + + logger.LogDebug("Telnyx webhook payload received: {@Webhook}", webhook); + await notificationSender.SendAsync(new TelnyxWebhookReceived(webhook), NotificationStrategy.Background, cancellationToken); + } + + private static async Task ReadRequestBodyAsync(HttpContext httpContext) + { + string body; + var req = httpContext.Request; + + // Allows using several time the stream in ASP.Net Core + req.EnableBuffering(); + + // Arguments: Stream, Encoding, detect encoding, buffer size AND, the most important: keep stream opened. + using (var reader = new StreamReader(req.Body, Encoding.UTF8, true, 1024, true)) + body = await reader.ReadToEndAsync(); + + // Rewind, so the core is not lost when it looks at the body for the request + req.Body.Position = 0; + return body; + } + + private static JsonSerializerOptions CreateSerializerSettings() + { + var settings = new JsonSerializerOptions + { + PropertyNamingPolicy = new SnakeCaseNamingPolicy() + }; + + settings.Converters.Add(new WebhookDataJsonConverter()); + return settings; + } +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/WebhookActivityTypeNames.cs b/src/Elsa.Integrations.Telnyx/WebhookActivityTypeNames.cs new file mode 100644 index 00000000..68caa19c --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/WebhookActivityTypeNames.cs @@ -0,0 +1,19 @@ +namespace Elsa.Integrations.Telnyx; + +public static class WebhookActivityTypeNames +{ + public const string CallBridged = $"{Constants.Namespace}.{nameof(CallBridged)}"; + public const string CallDtmfReceived = $"{Constants.Namespace}.{nameof(CallDtmfReceived)}"; + public const string CallGatherEnded = $"{Constants.Namespace}.{nameof(CallGatherEnded)}"; + public const string CallHangup = $"{Constants.Namespace}.{nameof(CallHangup)}"; + public const string CallInitiated = $"{Constants.Namespace}.{nameof(CallInitiated)}"; + public const string CallMachineGreetingEnded = $"{Constants.Namespace}.{nameof(CallMachineGreetingEnded)}"; + public const string CallMachinePremiumGreetingEnded = $"{Constants.Namespace}.{nameof(CallMachinePremiumGreetingEnded)}"; + public const string CallMachineDetectionEnded = $"{Constants.Namespace}.{nameof(CallMachineDetectionEnded)}"; + public const string CallMachinePremiumDetectionEnded = $"{Constants.Namespace}.{nameof(CallMachinePremiumDetectionEnded)}"; + public const string CallPlaybackStarted = $"{Constants.Namespace}.{nameof(CallPlaybackStarted)}"; + public const string CallPlaybackEnded = $"{Constants.Namespace}.{nameof(CallPlaybackEnded)}"; + public const string CallRecordingSaved = $"{Constants.Namespace}.{nameof(CallRecordingSaved)}"; + public const string CallSpeakStarted = $"{Constants.Namespace}.{nameof(CallSpeakStarted)}"; + public const string CallSpeakEnded = $"{Constants.Namespace}.{nameof(CallSpeakEnded)}"; +} \ No newline at end of file diff --git a/src/Elsa.Integrations.Telnyx/WebhookEventTypes.cs b/src/Elsa.Integrations.Telnyx/WebhookEventTypes.cs new file mode 100644 index 00000000..82c23715 --- /dev/null +++ b/src/Elsa.Integrations.Telnyx/WebhookEventTypes.cs @@ -0,0 +1,20 @@ +namespace Elsa.Integrations.Telnyx; + +public static class WebhookEventTypes +{ + public const string CallAnswered = "call.answered"; + public const string CallBridged = "call.bridged"; + public const string CallDtmfReceived = "call.dtmf.received"; + public const string CallGatherEnded = "call.gather.ended"; + public const string CallHangup = "call.hangup"; + public const string CallInitiated = "call.initiated"; + public const string CallMachineGreetingEnded = "call.machine.greeting.ended"; + public const string CallMachinePremiumGreetingEnded = "call.machine.premium.greeting.ended"; + public const string CallMachineDetectionEnded = "call.machine.detection.ended"; + public const string CallMachinePremiumDetectionEnded = "call.machine.premium.detection.ended"; + public const string CallPlaybackStarted = "call.playback.started"; + public const string CallPlaybackEnded = "call.playback.ended"; + public const string CallRecordingSaved = "call.recording.saved"; + public const string CallSpeakStarted = "call.speak.started"; + public const string CallSpeakEnded = "call.speak.ended"; +} \ No newline at end of file