use std::any::Any;
use std::sync::Arc;
use async_trait::async_trait;
use crate::error::Result;
use crate::processor::frame::{CustomFrame, Frame, FrameClass, KeypadEntry};
use crate::processor::{Envelope, FrameProcessor, Link};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IvrAction {
Dtmf(Vec<KeypadEntry>),
Say(String),
Wait,
}
#[derive(Debug, Clone)]
pub struct IvrStep {
pub triggers: Vec<String>,
pub action: IvrAction,
}
impl IvrStep {
pub fn press(triggers: &[&str], digits: Vec<KeypadEntry>) -> Self {
Self {
triggers: triggers.iter().map(|s| s.to_lowercase()).collect(),
action: IvrAction::Dtmf(digits),
}
}
pub fn say(triggers: &[&str], reply: impl Into<String>) -> Self {
Self {
triggers: triggers.iter().map(|s| s.to_lowercase()).collect(),
action: IvrAction::Say(reply.into()),
}
}
fn matches(&self, prompt_lower: &str) -> bool {
self.triggers.iter().any(|t| prompt_lower.contains(t))
}
}
#[derive(Debug, Clone, Default)]
pub struct Script {
steps: Vec<IvrStep>,
stuck_after: usize,
}
impl Script {
pub fn new(steps: Vec<IvrStep>, stuck_after: usize) -> Self {
Self { steps, stuck_after }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IvrStatus {
Completed,
Stuck,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IvrStatusChanged {
pub status: IvrStatus,
}
impl CustomFrame for IvrStatusChanged {
fn frame_class(&self) -> FrameClass {
FrameClass::Control
}
fn name(&self) -> &'static str {
"IvrStatusChanged"
}
fn as_any(&self) -> &dyn Any {
self
}
}
pub struct IvrNavigator {
name: &'static str,
script: Script,
cursor: usize,
unmatched_streak: usize,
finished: bool,
}
impl IvrNavigator {
pub fn new(script: Script) -> Self {
Self {
name: "ivr-navigator",
script,
cursor: 0,
unmatched_streak: 0,
finished: false,
}
}
pub fn is_finished(&self) -> bool {
self.finished
}
fn action_frame(action: &IvrAction) -> Option<Frame> {
match action {
IvrAction::Dtmf(digits) => Some(Frame::OutputDtmf(digits.clone())),
IvrAction::Say(reply) => Some(Frame::TtsSpeak {
text: reply.clone(),
append_to_context: Some(false),
}),
IvrAction::Wait => None,
}
}
}
#[async_trait]
impl FrameProcessor for IvrNavigator {
fn name(&self) -> &str {
self.name
}
async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
let Frame::Transcription { ref text, .. } = env.frame else {
link.push(env.meta, env.frame, env.direction).await;
return Ok(());
};
let direction = env.direction;
let prompt_lower = text.to_lowercase();
link.push(env.meta, env.frame, direction).await;
if self.finished {
return Ok(());
}
if let Some(step) = self.script.steps.get(self.cursor) {
if step.matches(&prompt_lower) {
self.unmatched_streak = 0;
if let Some(frame) = Self::action_frame(&step.action) {
link.push_down(frame).await;
}
self.cursor += 1;
if self.cursor >= self.script.steps.len() {
self.finished = true;
link.push_down(Frame::Custom(Arc::new(IvrStatusChanged {
status: IvrStatus::Completed,
})))
.await;
}
return Ok(());
}
}
self.unmatched_streak += 1;
if self.script.stuck_after > 0 && self.unmatched_streak >= self.script.stuck_after {
self.finished = true;
link.push_down(Frame::Custom(Arc::new(IvrStatusChanged {
status: IvrStatus::Stuck,
})))
.await;
}
Ok(())
}
}
pub fn as_ivr_status(frame: &Frame) -> Option<&IvrStatusChanged> {
match frame {
Frame::Custom(c) => c.as_any().downcast_ref::<IvrStatusChanged>(),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::test_harness::drive;
use crate::processor::frame::Direction;
fn prompt(text: &str) -> Frame {
Frame::Transcription {
text: text.into(),
user_id: Arc::from("ivr"),
language: None,
final_: true,
}
}
fn dtmf_of(frame: &Frame) -> Option<Vec<KeypadEntry>> {
match frame {
Frame::OutputDtmf(d) => Some(d.clone()),
_ => None,
}
}
#[tokio::test]
async fn navigates_a_menu_path_and_completes() {
let script = Script::new(
vec![
IvrStep::press(
&["press 2 for billing", "for billing press 2"],
vec![KeypadEntry::Two],
),
IvrStep::press(&["enter your account number"], vec![KeypadEntry::One]),
IvrStep::say(&["say yes or no", "is this correct"], "Yes."),
],
3,
);
let out = drive(
Box::new(IvrNavigator::new(script)),
vec![
prompt("Thank you for calling. Press 1 for sales, press 2 for billing."),
prompt("Please enter your account number followed by pound."),
prompt("I heard account one. Is this correct? Say yes or no."),
],
Direction::Downstream,
)
.await;
let dtmfs: Vec<Vec<KeypadEntry>> = out.iter().filter_map(dtmf_of).collect();
assert_eq!(dtmfs, vec![vec![KeypadEntry::Two], vec![KeypadEntry::One]]);
let said = out
.iter()
.any(|f| matches!(f, Frame::TtsSpeak { text, .. } if text == "Yes."));
assert!(said, "verbal step did not speak: {out:?}");
let status = out.iter().find_map(as_ivr_status).expect("no status");
assert_eq!(status.status, IvrStatus::Completed);
}
#[tokio::test]
async fn waits_through_irrelevant_prompts_then_matches() {
let script = Script::new(
vec![IvrStep::press(
&["press 0 for an agent"],
vec![KeypadEntry::Zero],
)],
5,
);
let out = drive(
Box::new(IvrNavigator::new(script)),
vec![
prompt("Your call is important to us."), prompt("Please continue to hold."), prompt("Or press 0 for an agent."), ],
Direction::Downstream,
)
.await;
let dtmfs: Vec<Vec<KeypadEntry>> = out.iter().filter_map(dtmf_of).collect();
assert_eq!(dtmfs, vec![vec![KeypadEntry::Zero]]);
let status = out.iter().find_map(as_ivr_status).expect("no status");
assert_eq!(status.status, IvrStatus::Completed);
}
#[tokio::test]
async fn reports_stuck_after_unmatched_streak() {
let script = Script::new(
vec![IvrStep::press(
&["press 9 for spanish"],
vec![KeypadEntry::Nine],
)],
2, );
let out = drive(
Box::new(IvrNavigator::new(script)),
vec![
prompt("Welcome to the help line."), prompt("All agents are busy."), prompt("press 9 for spanish"), ],
Direction::Downstream,
)
.await;
let status = out.iter().find_map(as_ivr_status).expect("no status");
assert_eq!(status.status, IvrStatus::Stuck);
assert!(out.iter().filter_map(dtmf_of).next().is_none());
}
#[tokio::test]
async fn non_transcription_frames_pass_through() {
let script = Script::new(vec![IvrStep::press(&["x"], vec![KeypadEntry::One])], 3);
let out = drive(
Box::new(IvrNavigator::new(script)),
vec![Frame::Text("hi".into())],
Direction::Downstream,
)
.await;
assert!(matches!(out.first(), Some(Frame::Text(t)) if t == "hi"));
}
}