diff --git a/src/activities/Elsa.Activities.Telnyx/Activities/CallRingGroup.cs b/src/activities/Elsa.Activities.Telnyx/Activities/CallRingGroup.cs index 2bbc0c831..b491c9dff 100644 --- a/src/activities/Elsa.Activities.Telnyx/Activities/CallRingGroup.cs +++ b/src/activities/Elsa.Activities.Telnyx/Activities/CallRingGroup.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Threading.Tasks; @@ -109,6 +110,16 @@ namespace Elsa.Activities.Telnyx.Activities set => SetState(value); } + [ActivityInput( + Hint = "The audio file to play while dialing.", + SupportedSyntaxes = new[] {SyntaxNames.JavaScript, SyntaxNames.Liquid} + )] + public Uri? MusicOnHold + { + get => GetState(); + set => SetState(value); + } + [ActivityInput( Hint = "Enables Answering Machine Detection.", UIHint = ActivityInputUIHints.Dropdown, @@ -130,9 +141,22 @@ namespace Elsa.Activities.Telnyx.Activities set => SetState(value); } - public override void Build(ICompositeActivityBuilder builder) => + public override void Build(ICompositeActivityBuilder builder) + { + // Root. builder - .StartWith(fork => fork.WithBranches("Ring", "Queue Timeout"), fork => + .If(() => MusicOnHold != null, @if => + { + @if + .When(OutcomeNames.True) + .Then(playAudio => playAudio.WithAudioUrl(() => MusicOnHold).WithLoop("infinity")) + .ThenNamed("MainFork"); + + @if + .When(OutcomeNames.False) + .ThenNamed("MainFork"); + }) + .Add(fork => fork.WithBranches("Ring", "Queue Timeout"), fork => { fork.When("Ring") .While(true, iterate => iterate @@ -140,16 +164,21 @@ namespace Elsa.Activities.Telnyx.Activities { cases.Add(RingGroupStrategy.PrioritizedHunt.ToString(), () => Strategy == RingGroupStrategy.PrioritizedHunt, BuildPrioritizedHuntFlow); cases.Add(RingGroupStrategy.RingAll.ToString(), () => Strategy == RingGroupStrategy.RingAll, BuildRingAllFlow); - })); + })) + .ThenNamed("ExitWithNoResponse"); fork.When("Queue Timeout") - .IfFalse(() => IsNullOrZero(MaxQueueWaitTime), whenTrue => - { - whenTrue - .Timer(() => MaxQueueWaitTime!.Value) - .Finish(TelnyxOutcomeNames.NoResponse); - }); - }); + .IfFalse(() => IsNullOrZero(MaxQueueWaitTime), ifFalse => ifFalse.StartIn(() => MaxQueueWaitTime!.Value).ThenNamed("ExitWithNoResponse")); + }).WithName("MainFork"); + + // No Response exit node. + builder.Add(@if => @if.WithCondition(() => MusicOnHold != null), @if => + { + @if.When(OutcomeNames.True).Then(stopAudioPlayback => stopAudioPlayback.When(TelnyxOutcomeNames.CallPlaybackEnded).Finish(TelnyxOutcomeNames.NoResponse)); + @if.When(OutcomeNames.False).Finish(TelnyxOutcomeNames.NoResponse); + }) + .WithName("ExitWithNoResponse"); + } protected override async ValueTask OnExitAsync(ActivityExecutionContext context, object? output) { @@ -185,13 +214,24 @@ namespace Elsa.Activities.Telnyx.Activities { dial .When(TelnyxOutcomeNames.Answered) - .Then(bridgeCalls => + .If(() => MusicOnHold != null, @if => + { + @if.When(OutcomeNames.True) + .Then(stopAudioPlayback => stopAudioPlayback + .When(TelnyxOutcomeNames.CallPlaybackEnded) + .ThenNamed("BridgeCalls1")); + + @if.When(OutcomeNames.False) + .ThenNamed("BridgeCalls1"); + }) + .Add(branch: bridgeCalls => bridgeCalls.When(TelnyxOutcomeNames.Bridged) - .Finish(activity => activity.WithOutcome(TelnyxOutcomeNames.Connected).WithOutput(context => context.GetInput()))); + .Finish(activity => activity.WithOutcome(TelnyxOutcomeNames.Connected).WithOutput(context => context.GetInput()))).WithName("BridgeCalls1"); } ) ) - .IfTrue(() => IsNullOrZero(MaxQueueWaitTime), whenTrue => whenTrue.Finish(TelnyxOutcomeNames.NoResponse)); + // If we don't have a queue wait time, then break out of outer loop. + .IfTrue(() => IsNullOrZero(MaxQueueWaitTime), @if => @if.Break()); private void BuildRingAllFlow(IOutcomeBuilder builder) => builder @@ -210,7 +250,12 @@ namespace Elsa.Activities.Telnyx.Activities fork .When("Timeout") .StartIn(() => RingTime) - .IfTrue(() => IsNullOrZero(MaxQueueWaitTime), whenTrue => whenTrue.Finish(TelnyxOutcomeNames.NoResponse)); + + // If we don't have a queue wait time, then break out of outer loop. + .If(@if => @if.WithCondition( () => IsNullOrZero(MaxQueueWaitTime)), @if => + { + @if.When(OutcomeNames.True).Break(); + }); fork .When("Dial Everyone") diff --git a/src/activities/Elsa.Activities.Telnyx/Activities/PlayAudio.cs b/src/activities/Elsa.Activities.Telnyx/Activities/PlayAudio.cs index 4a1c7c151..69bb1514f 100644 --- a/src/activities/Elsa.Activities.Telnyx/Activities/PlayAudio.cs +++ b/src/activities/Elsa.Activities.Telnyx/Activities/PlayAudio.cs @@ -5,6 +5,7 @@ using Elsa.Activities.Telnyx.Client.Services; using Elsa.Activities.Telnyx.Extensions; using Elsa.ActivityResults; using Elsa.Attributes; +using Elsa.Builders; using Elsa.Design; using Elsa.Exceptions; using Elsa.Expressions; @@ -109,4 +110,37 @@ namespace Elsa.Activities.Telnyx.Activities private static string? EmptyToNull(string? value) => value is "" ? null : value; } + + public static class PlayAudioExtensions + { + public static ISetupActivity WithCallControlId(this ISetupActivity setup, Func> value) => setup.Set(x => x.CallControlId, value); + public static ISetupActivity WithCallControlId(this ISetupActivity setup, Func value) => setup.Set(x => x.CallControlId, value); + public static ISetupActivity WithCallControlId(this ISetupActivity setup, Func> value) => setup.Set(x => x.CallControlId, value); + public static ISetupActivity WithCallControlId(this ISetupActivity setup, Func value) => setup.Set(x => x.CallControlId, value); + public static ISetupActivity WithCallControlId(this ISetupActivity setup, string? value) => setup.Set(x => x.CallControlId, value); + + public static ISetupActivity WithAudioUrl(this ISetupActivity setup, Func> value) => setup.Set(x => x.AudioUrl, value); + public static ISetupActivity WithAudioUrl(this ISetupActivity setup, Func value) => setup.Set(x => x.AudioUrl, value); + public static ISetupActivity WithAudioUrl(this ISetupActivity setup, Func> value) => setup.Set(x => x.AudioUrl, value); + public static ISetupActivity WithAudioUrl(this ISetupActivity setup, Func value) => setup.Set(x => x.AudioUrl, value); + public static ISetupActivity WithAudioUrl(this ISetupActivity setup, Uri? value) => setup.Set(x => x.AudioUrl, value); + + public static ISetupActivity WithLoop(this ISetupActivity setup, Func> value) => setup.Set(x => x.Loop, value); + public static ISetupActivity WithLoop(this ISetupActivity setup, Func value) => setup.Set(x => x.Loop, value); + public static ISetupActivity WithLoop(this ISetupActivity setup, Func> value) => setup.Set(x => x.Loop, value); + public static ISetupActivity WithLoop(this ISetupActivity setup, Func value) => setup.Set(x => x.Loop, value); + public static ISetupActivity WithLoop(this ISetupActivity setup, string? value) => setup.Set(x => x.Loop, value); + + public static ISetupActivity WithOverlay(this ISetupActivity setup, Func> value) => setup.Set(x => x.Overlay, value); + public static ISetupActivity WithOverlay(this ISetupActivity setup, Func value) => setup.Set(x => x.Overlay, value); + public static ISetupActivity WithOverlay(this ISetupActivity setup, Func> value) => setup.Set(x => x.Overlay, value); + public static ISetupActivity WithOverlay(this ISetupActivity setup, Func value) => setup.Set(x => x.Overlay, value); + public static ISetupActivity WithOverlay(this ISetupActivity setup, bool? value) => setup.Set(x => x.Overlay, value); + + public static ISetupActivity WithTargetLegs(this ISetupActivity setup, Func> value) => setup.Set(x => x.TargetLegs, value); + public static ISetupActivity WithTargetLegs(this ISetupActivity setup, Func value) => setup.Set(x => x.TargetLegs, value); + public static ISetupActivity WithTargetLegs(this ISetupActivity setup, Func> value) => setup.Set(x => x.TargetLegs, value); + public static ISetupActivity WithTargetLegs(this ISetupActivity setup, Func value) => setup.Set(x => x.TargetLegs, value); + public static ISetupActivity WithTargetLegs(this ISetupActivity setup, string? value) => setup.Set(x => x.TargetLegs, value); + } } \ No newline at end of file diff --git a/src/activities/Elsa.Activities.Telnyx/Activities/StopAudioPlayback.cs b/src/activities/Elsa.Activities.Telnyx/Activities/StopAudioPlayback.cs new file mode 100644 index 000000000..d7549f99f --- /dev/null +++ b/src/activities/Elsa.Activities.Telnyx/Activities/StopAudioPlayback.cs @@ -0,0 +1,112 @@ +using System; +using System.Threading.Tasks; +using Elsa.Activities.Telnyx.Client.Models; +using Elsa.Activities.Telnyx.Client.Services; +using Elsa.Activities.Telnyx.Extensions; +using Elsa.Activities.Telnyx.Webhooks.Payloads.Call; +using Elsa.ActivityResults; +using Elsa.Attributes; +using Elsa.Builders; +using Elsa.Design; +using Elsa.Exceptions; +using Elsa.Expressions; +using Elsa.Services; +using Elsa.Services.Models; +using Refit; + +namespace Elsa.Activities.Telnyx.Activities +{ + [Action( + Category = Constants.Category, + Description = "Play an audio file on the call.", + Outcomes = new[] { TelnyxOutcomeNames.CallPlaybackEnding, TelnyxOutcomeNames.CallIsNoLongerActive, TelnyxOutcomeNames.CallPlaybackEnded }, + DisplayName = "Play Audio" + )] + public class StopAudioPlayback : Activity + { + private readonly ITelnyxClient _telnyxClient; + public StopAudioPlayback(ITelnyxClient telnyxClient) => _telnyxClient = telnyxClient; + + [ActivityInput( + Label = "Call Control ID", + Hint = "Unique identifier and token for controlling the call", + Category = PropertyCategories.Advanced, + SupportedSyntaxes = new[] { SyntaxNames.JavaScript, SyntaxNames.Liquid } + )] + public string? CallControlId { get; set; } = default!; + + [ActivityInput( + Hint = "Use this field to add state to every subsequent webhook. It must be a valid Base-64 encoded string.", + Category = PropertyCategories.Advanced, + SupportedSyntaxes = new[] { SyntaxNames.JavaScript, SyntaxNames.Liquid } + )] + public string? ClientState { get; set; } + + [ActivityInput( + Label = "Command ID", + Hint = "Use this field to avoid duplicate commands. Telnyx will ignore commands with the same Command ID.", + Category = PropertyCategories.Advanced, + SupportedSyntaxes = new[] { SyntaxNames.JavaScript, SyntaxNames.Liquid } + )] + public string? CommandId { get; set; } + + [ActivityInput( + Hint = "Use 'current' to stop only the current audio or 'all' to stop all audios in the queue.", + DefaultValue = "all", + Category = PropertyCategories.Advanced, + SupportedSyntaxes = new[] { SyntaxNames.JavaScript, SyntaxNames.Liquid } + )] + public string? Stop { get; set; } = "all"; + + [ActivityOutput(Hint = "The received payload when speaking ended.")] + public CallPlaybackEndedPayload? PlaybackEndedPayload { get; set; } + + protected override async ValueTask OnExecuteAsync(ActivityExecutionContext context) + { + var request = new StopAudioPlaybackRequest( + EmptyToNull(ClientState), + EmptyToNull(CommandId), + Stop + ); + + var callControlId = context.GetCallControlId(CallControlId); + + try + { + await _telnyxClient.Calls.StopAudioPlaybackAsync(callControlId, request, context.CancellationToken); + return Combine(Outcome(TelnyxOutcomeNames.CallPlaybackEnding), Suspend()); + } + catch (ApiException e) + { + if (await e.CallIsNoLongerActiveAsync()) + return Outcome(TelnyxOutcomeNames.CallIsNoLongerActive); + + throw new WorkflowException(e.Content ?? e.Message, e); + } + } + + protected override IActivityExecutionResult OnResume(ActivityExecutionContext context) + { + PlaybackEndedPayload = context.GetInput(); + return Outcome(TelnyxOutcomeNames.CallPlaybackEnded); + } + + private static string? EmptyToNull(string? value) => value is "" ? null : value; + } + + public static class StopAudioPlaybackExtensions + { + public static ISetupActivity WithCallControlId(this ISetupActivity setup, Func> value) => setup.Set(x => x.CallControlId, value); + public static ISetupActivity WithCallControlId(this ISetupActivity setup, Func value) => setup.Set(x => x.CallControlId, value); + public static ISetupActivity WithCallControlId(this ISetupActivity setup, Func> value) => setup.Set(x => x.CallControlId, value); + public static ISetupActivity WithCallControlId(this ISetupActivity setup, Func value) => setup.Set(x => x.CallControlId, value); + public static ISetupActivity WithCallControlId(this ISetupActivity setup, string? value) => setup.Set(x => x.CallControlId, value); + + public static ISetupActivity WithStop(this ISetupActivity setup, Func> value) => setup.Set(x => x.Stop, value); + public static ISetupActivity WithStop(this ISetupActivity setup, Func value) => setup.Set(x => x.Stop, value); + public static ISetupActivity WithStop(this ISetupActivity setup, Func> value) => setup.Set(x => x.Stop, value); + public static ISetupActivity WithStop(this ISetupActivity setup, Func value) => setup.Set(x => x.Stop, value); + public static ISetupActivity WithStop(this ISetupActivity setup, string? value) => setup.Set(x => x.Stop, value); + + } +} \ No newline at end of file diff --git a/src/activities/Elsa.Activities.Telnyx/Client/Models/Requests.cs b/src/activities/Elsa.Activities.Telnyx/Client/Models/Requests.cs index c6cb7bcec..2b0764424 100644 --- a/src/activities/Elsa.Activities.Telnyx/Client/Models/Requests.cs +++ b/src/activities/Elsa.Activities.Telnyx/Client/Models/Requests.cs @@ -90,6 +90,12 @@ namespace Elsa.Activities.Telnyx.Client.Models bool Overlay, string? TargetLegs ); + + public record StopAudioPlaybackRequest( + string? ClientState, + string? CommandId, + string? Stop + ); public record StartRecordingRequest( string Channels, diff --git a/src/activities/Elsa.Activities.Telnyx/Client/Services/ICallsApi.cs b/src/activities/Elsa.Activities.Telnyx/Client/Services/ICallsApi.cs index b0d1d7ce4..d52e153b0 100644 --- a/src/activities/Elsa.Activities.Telnyx/Client/Services/ICallsApi.cs +++ b/src/activities/Elsa.Activities.Telnyx/Client/Services/ICallsApi.cs @@ -31,6 +31,9 @@ namespace Elsa.Activities.Telnyx.Client.Services [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); diff --git a/src/activities/Elsa.Activities.Telnyx/Handlers/ResumeStopAudioPlayback.cs b/src/activities/Elsa.Activities.Telnyx/Handlers/ResumeStopAudioPlayback.cs new file mode 100644 index 000000000..df9d94dfb --- /dev/null +++ b/src/activities/Elsa.Activities.Telnyx/Handlers/ResumeStopAudioPlayback.cs @@ -0,0 +1,13 @@ +using Elsa.Activities.Telnyx.Activities; +using Elsa.Activities.Telnyx.Webhooks.Payloads.Call; +using Elsa.Services; + +namespace Elsa.Activities.Telnyx.Handlers +{ + public class ResumeStopAudioPlayback : ResumeWebhookDrivenActivity + { + public ResumeStopAudioPlayback(IWorkflowLaunchpad workflowLaunchpad) : base(workflowLaunchpad) + { + } + } +} \ No newline at end of file diff --git a/src/activities/Elsa.Activities.Telnyx/Handlers/ResumeWebhookDrivenActivity.cs b/src/activities/Elsa.Activities.Telnyx/Handlers/ResumeWebhookDrivenActivity.cs index 6998befe8..390bc9051 100644 --- a/src/activities/Elsa.Activities.Telnyx/Handlers/ResumeWebhookDrivenActivity.cs +++ b/src/activities/Elsa.Activities.Telnyx/Handlers/ResumeWebhookDrivenActivity.cs @@ -13,7 +13,7 @@ using MediatR; namespace Elsa.Activities.Telnyx.Handlers { - public abstract class ResumeWebhookDrivenActivity : ResumeWebhookDrivenActivity where TPayload : CallPayload + public abstract class ResumeWebhookDrivenActivity : ResumeWebhookDrivenActivity where TPayload : CallPayload where TActivity : IActivity { protected ResumeWebhookDrivenActivity(IWorkflowLaunchpad workflowLaunchpad) : base(workflowLaunchpad) { @@ -22,7 +22,7 @@ namespace Elsa.Activities.Telnyx.Handlers protected override IEnumerable GetSupportedPayloadTypes() => new[] {typeof(TPayload)}; } - public abstract class ResumeWebhookDrivenActivity : INotificationHandler + public abstract class ResumeWebhookDrivenActivity : INotificationHandler where TActivity : IActivity { private readonly IWorkflowLaunchpad _workflowLaunchpad; protected ResumeWebhookDrivenActivity(IWorkflowLaunchpad workflowLaunchpad) => _workflowLaunchpad = workflowLaunchpad; diff --git a/src/activities/Elsa.Activities.Telnyx/OutcomeNames.cs b/src/activities/Elsa.Activities.Telnyx/OutcomeNames.cs index d9fb78766..47462409a 100644 --- a/src/activities/Elsa.Activities.Telnyx/OutcomeNames.cs +++ b/src/activities/Elsa.Activities.Telnyx/OutcomeNames.cs @@ -23,5 +23,7 @@ public const string Speaking = "Speaking"; public const string Recording = "Recording"; public const string FinishedRecording = "Finished Recording"; + public const string CallPlaybackEnding = "Playback Ending"; + public const string CallPlaybackEnded = "Playback Ended"; } } \ No newline at end of file diff --git a/src/activities/Elsa.Activities.Telnyx/Providers/Bookmarks/StopAudioPlaybackBookmark.cs b/src/activities/Elsa.Activities.Telnyx/Providers/Bookmarks/StopAudioPlaybackBookmark.cs new file mode 100644 index 000000000..d2c5be142 --- /dev/null +++ b/src/activities/Elsa.Activities.Telnyx/Providers/Bookmarks/StopAudioPlaybackBookmark.cs @@ -0,0 +1,13 @@ +using Elsa.Activities.Telnyx.Activities; +using Elsa.Services.Bookmarks; + +namespace Elsa.Activities.Telnyx.Providers.Bookmarks +{ + public class StopAudioPlaybackBookmark : IBookmark + { + } + + public class StopAudioPlaybackBookmarkProvider : DefaultBookmarkProvider + { + } +} \ No newline at end of file