diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Models/AssistantMessage.cs b/src/Plugins/BotSharp.Plugin.Twilio/Models/AssistantMessage.cs index 87f3e4377..ed497a3e3 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Models/AssistantMessage.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Models/AssistantMessage.cs @@ -13,5 +13,17 @@ public class AssistantMessage /// What of the reply is said once the turn is over must play to the end, as the flow asked when the reply came in /// public bool KeepUninterrupted { get; set; } + + /// + /// The caller is busy on their phone and silence is expected (), as + /// the flow had it when the reply came in + /// + public bool QuietWait { get; set; } + + /// + /// What to say when hanging up on a caller who stays quiet from now on, in place of the idle-goodbye prompt + /// (); null for the prompt + /// + public string? GoodbyeOnSilence { get; set; } } } diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Models/ConversationRelayLabels.cs b/src/Plugins/BotSharp.Plugin.Twilio/Models/ConversationRelayLabels.cs index 9214331a1..1ffae7995 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Models/ConversationRelayLabels.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Models/ConversationRelayLabels.cs @@ -13,8 +13,9 @@ public static class ConversationRelayLabels /// /// Said to a caller gone quiet: the last question asked again ("Sorry, I didn't catch that. ..."), "Are you still - /// there?", or "Sorry, can you say it again?". It checks the caller is there, so it is no line of the agent's to say - /// again when they ask what was said. + /// there?", or "Sorry, can you say it again?"; while the flow waits quietly, the soft check-in ("I'm still here + /// whenever you're ready."). It checks the caller is there, so it is no line of the agent's to say again when they ask + /// what was said. /// public const string IdlePrompt = "twilio_relay_idle_prompt"; diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Models/ConversationRelayStates.cs b/src/Plugins/BotSharp.Plugin.Twilio/Models/ConversationRelayStates.cs index a742862b7..07e57503f 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Models/ConversationRelayStates.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Models/ConversationRelayStates.cs @@ -17,4 +17,19 @@ public static class ConversationRelayStates /// that text whole, so this is the only sign. Set by the session at the start of every turn. /// public const string LastReplyCutOff = "twilio_relay_last_reply_cut_off"; + + /// + /// "true" while the caller is busy with something the agent asked them to do on their phone, such as filling in a form + /// it texted them, and the agent waits quietly: silence is expected, so instead of the idle prompts the caller hears a + /// soft check-in (prompt quiet-wait-check-in) each QuietWaitSeconds, and the call is hung up only after + /// MaxQuietWaitPrompts of them. Read when a turn's reply is in; a flow clears it once the wait is over. + /// + public const string QuietWait = "twilio_relay_quiet_wait"; + + /// + /// What is said as the session hangs up on a caller who stayed quiet, in place of the idle-goodbye prompt: a flow sets + /// it while something is still to be told, such as the confirmation number of a request already made, and clears it + /// once that is said. Read when a turn's reply is in. + /// + public const string GoodbyeOnSilence = "twilio_relay_goodbye_on_silence"; } diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Services/ConversationRelaySession.cs b/src/Plugins/BotSharp.Plugin.Twilio/Services/ConversationRelaySession.cs index a323d7abc..c5f7a689c 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Services/ConversationRelaySession.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Services/ConversationRelaySession.cs @@ -20,6 +20,7 @@ public class ConversationRelaySession private const string DefaultAskAgainText = "Sorry, I didn't catch that. {0}"; private const string DefaultStillThereText = "Are you still there?"; private const string DefaultIdleGoodbyeText = "It sounds like we got disconnected. Please call back anytime."; + private const string DefaultQuietWaitCheckInText = "I'm still here whenever you're ready."; private const double SpokenCharsPerSecond = 15; private static readonly List DefaultWatchdogPrompts = ["hold-on-1", "hold-on-2", "hold-on-3", "hold-on-4", "hold-on-5"]; private static readonly List DefaultWatchdogLongPrompts = Enumerable.Range(1, 10).Select(i => $"hold-on-long-{i}").ToList(); @@ -95,6 +96,11 @@ public class ConversationRelaySession // turns: until a turn has read it, the last turn's answer is the best guess. private bool _keptUninterrupted; + // What the flow said, as the last reply came in, about a caller going quiet (ConversationRelayStates.QuietWait and + // GoodbyeOnSilence): kept until a later reply says otherwise, as a turn that says nothing still reads them + private bool _quietWait; + private string? _goodbyeOnSilence; + // The caller went on talking before the current turn said anything: its reply is dropped, and their words so far // are answered together in the next turn private bool _superseded; @@ -638,6 +644,16 @@ private async Task ProcessPendingAsync() } await CloseUtteranceAsync(); + if (reply != null) + { + // Before a reply with nothing to say is passed over: a flow waiting quietly answers the caller's asides + // with nothing + lock (_sync) + { + _quietWait = reply.QuietWait; + _goodbyeOnSilence = reply.GoodbyeOnSilence; + } + } if (reply == null || string.IsNullOrWhiteSpace(reply.Content) && !reply.ConversationEnd && !reply.HumanIntervationNeeded) { @@ -823,22 +839,26 @@ private async Task WatchIdleAsync(CancellationToken cancellationToken) var activity = 0; var stage = 0; string? question = null; + var quietWait = false; + string? goodbyeOnSilence = null; lock (_sync) { var quietFor = DateTime.UtcNow - _quietSinceUtc; if (_ended || _isProcessing || _quietSinceUtc == DateTime.MaxValue || _resumeLine != null - || quietFor < IdleTimeout(_idlePrompts)) + || quietFor < IdleTimeout(_idlePrompts, _quietWait)) { continue; } stage = _idlePrompts; - if (_idlePrompts < relay.MaxIdlePrompts) + quietWait = _quietWait; + goodbyeOnSilence = _goodbyeOnSilence; + if (_idlePrompts < (quietWait ? relay.MaxQuietWaitPrompts : relay.MaxIdlePrompts)) { _idlePrompts++; askAgain = true; activity = _activity; - question = relay.IdleAsksAgain && stage == 0 ? ConversationRelayLines.LastQuestion(_lastLine) : null; + question = !quietWait && relay.IdleAsksAgain && stage == 0 ? ConversationRelayLines.LastQuestion(_lastLine) : null; } else { @@ -852,7 +872,15 @@ private async Task WatchIdleAsync(CancellationToken cancellationToken) // Rechecked once the send lock is held: caller input arriving meanwhile starts a turn, and the prompt must not // then be said over or after that turn's reply bool StillSilent() => !_isProcessing && _activity == activity; - if (askAgain && question != null) + // The caller is busy on their phone: nothing to say again and no "Are you still there?", only that the agent + // is still on the line. Why: the previous build read that silence as a dead call and hung up on callers + // filling in the form it had texted them + if (askAgain && quietWait) + { + await SayOwnLineAsync(PromptText("quiet-wait-check-in", DefaultQuietWaitCheckInText), ConversationRelayLabels.IdlePrompt, + StillSilent); + } + else if (askAgain && question != null) { await SayOwnLineAsync(PromptText("say-it-again-question", DefaultAskAgainText).Replace("{0}", question), ConversationRelayLabels.IdlePrompt, StillSilent); @@ -873,11 +901,13 @@ await SayOwnLineAsync(PromptText($"say-it-again-{Random.Shared.Next(1, 5)}", Def _logger.LogInformation($"Caller stayed silent, hanging up conversation {_conversationId}."); string? goodbye = null; var goodbyeUtc = DateTime.MinValue; - if (relay.IdleAsksAgain) + // The flow's own goodbye is said whether or not the idle prompts say one: it tells what the caller still has + // to hear, such as a confirmation number + if (goodbyeOnSilence != null || relay.IdleAsksAgain) { // The session is marked ended already, so nothing else can be said after it, and nothing the caller // says over it is answered: it plays to the end - goodbye = PromptText("idle-goodbye", DefaultIdleGoodbyeText); + goodbye = goodbyeOnSilence ?? PromptText("idle-goodbye", DefaultIdleGoodbyeText); await SendAsync(TextFrame(goodbye, last: true, keepUninterrupted: true)); goodbyeUtc = DateTime.UtcNow; } @@ -892,8 +922,12 @@ await SayOwnLineAsync(PromptText($"say-it-again-{Random.Shared.Next(1, 5)}", Def } // How long the caller may stay silent before the idle prompt after this many (the last is before the hang-up) - private TimeSpan IdleTimeout(int prompts) + private TimeSpan IdleTimeout(int prompts, bool quietWait) { + if (quietWait) + { + return TimeSpan.FromSeconds(_relay.QuietWaitSeconds); + } var timeouts = _relay.IdleTimeouts; return TimeSpan.FromSeconds(timeouts.Count > 0 ? timeouts[Math.Min(prompts, timeouts.Count - 1)] : _relay.IdleTimeoutSeconds); } diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioConversationProcessor.cs b/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioConversationProcessor.cs index 3681ee0ae..8705b29c7 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioConversationProcessor.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioConversationProcessor.cs @@ -167,7 +167,10 @@ await KeepSaidLines(sp, message.AgentId, message.ConversationId, inputMsg.Messag HumanIntervationNeeded = string.Equals("human_intervention_needed", msg.FunctionName), Content = spoken, MessageId = msg.MessageId, - KeepUninterrupted = callbacks?.KeepUninterrupted?.Invoke() ?? false + KeepUninterrupted = callbacks?.KeepUninterrupted?.Invoke() ?? false, + QuietWait = conv.States.GetState(ConversationRelayStates.QuietWait) == "true", + GoodbyeOnSilence = conv.States.GetState(ConversationRelayStates.GoodbyeOnSilence) is { Length: > 0 } goodbye + ? goodbye : null }; } ); diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Settings/ConversationRelayAgentSetting.cs b/src/Plugins/BotSharp.Plugin.Twilio/Settings/ConversationRelayAgentSetting.cs index 3e6140ae7..67a9823c5 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Settings/ConversationRelayAgentSetting.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Settings/ConversationRelayAgentSetting.cs @@ -19,6 +19,8 @@ public class ConversationRelayAgentSetting public int? MaxIdlePrompts { get; set; } public List? IdleTimeouts { get; set; } public bool? IdleAsksAgain { get; set; } + public double? QuietWaitSeconds { get; set; } + public int? MaxQuietWaitPrompts { get; set; } public double? ResumeAfterCutInSeconds { get; set; } public bool? DropBackchannelTails { get; set; } } diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Settings/ConversationRelaySetting.cs b/src/Plugins/BotSharp.Plugin.Twilio/Settings/ConversationRelaySetting.cs index f4b34420b..5ce5fb7c5 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Settings/ConversationRelaySetting.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Settings/ConversationRelaySetting.cs @@ -125,6 +125,18 @@ public class ConversationRelaySetting /// public bool IdleAsksAgain { get; set; } + /// + /// Seconds of caller silence before each soft check-in ("I'm still here whenever you're ready.", prompt + /// quiet-wait-check-in) while a flow waits for the caller to do something on their phone + /// (); the last one is before hanging up + /// + public double QuietWaitSeconds { get; set; } = 60; + + /// + /// How many soft check-ins a caller who stays silent while the flow waits quietly hears before the call is hung up + /// + public int MaxQuietWaitPrompts { get; set; } = 4; + /// /// Drop a caller's words that came in before they could hear the line just said (TooSoonToAnswer) when they only /// acknowledge ("I agree.", "Okay.") and that line asks something: the tail of what they said before, which the @@ -156,6 +168,8 @@ public ConversationRelaySetting For(string? agentId) settings.MaxIdlePrompts = agent.MaxIdlePrompts ?? MaxIdlePrompts; settings.IdleTimeouts = agent.IdleTimeouts ?? IdleTimeouts; settings.IdleAsksAgain = agent.IdleAsksAgain ?? IdleAsksAgain; + settings.QuietWaitSeconds = agent.QuietWaitSeconds ?? QuietWaitSeconds; + settings.MaxQuietWaitPrompts = agent.MaxQuietWaitPrompts ?? MaxQuietWaitPrompts; settings.ResumeAfterCutInSeconds = agent.ResumeAfterCutInSeconds ?? ResumeAfterCutInSeconds; settings.DropBackchannelTails = agent.DropBackchannelTails ?? DropBackchannelTails; return settings;