1use std::any::Any;
23use std::sync::Arc;
24
25use async_trait::async_trait;
26
27use crate::error::Result;
28use crate::processor::frame::{CustomFrame, Frame, FrameClass, KeypadEntry};
29use crate::processor::{Envelope, FrameProcessor, Link};
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum IvrAction {
34 Dtmf(Vec<KeypadEntry>),
36 Say(String),
38 Wait,
41}
42
43#[derive(Debug, Clone)]
46pub struct IvrStep {
47 pub triggers: Vec<String>,
49 pub action: IvrAction,
51}
52
53impl IvrStep {
54 pub fn press(triggers: &[&str], digits: Vec<KeypadEntry>) -> Self {
56 Self {
57 triggers: triggers.iter().map(|s| s.to_lowercase()).collect(),
58 action: IvrAction::Dtmf(digits),
59 }
60 }
61
62 pub fn say(triggers: &[&str], reply: impl Into<String>) -> Self {
64 Self {
65 triggers: triggers.iter().map(|s| s.to_lowercase()).collect(),
66 action: IvrAction::Say(reply.into()),
67 }
68 }
69
70 fn matches(&self, prompt_lower: &str) -> bool {
71 self.triggers.iter().any(|t| prompt_lower.contains(t))
72 }
73}
74
75#[derive(Debug, Clone, Default)]
77pub struct Script {
78 steps: Vec<IvrStep>,
79 stuck_after: usize,
81}
82
83impl Script {
84 pub fn new(steps: Vec<IvrStep>, stuck_after: usize) -> Self {
87 Self { steps, stuck_after }
88 }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum IvrStatus {
95 Completed,
97 Stuck,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub struct IvrStatusChanged {
104 pub status: IvrStatus,
106}
107
108impl CustomFrame for IvrStatusChanged {
109 fn frame_class(&self) -> FrameClass {
110 FrameClass::Control
111 }
112 fn name(&self) -> &'static str {
113 "IvrStatusChanged"
114 }
115 fn as_any(&self) -> &dyn Any {
116 self
117 }
118}
119
120pub struct IvrNavigator {
123 name: &'static str,
124 script: Script,
125 cursor: usize,
126 unmatched_streak: usize,
127 finished: bool,
128}
129
130impl IvrNavigator {
131 pub fn new(script: Script) -> Self {
133 Self {
134 name: "ivr-navigator",
135 script,
136 cursor: 0,
137 unmatched_streak: 0,
138 finished: false,
139 }
140 }
141
142 pub fn is_finished(&self) -> bool {
144 self.finished
145 }
146
147 fn action_frame(action: &IvrAction) -> Option<Frame> {
149 match action {
150 IvrAction::Dtmf(digits) => Some(Frame::OutputDtmf(digits.clone())),
151 IvrAction::Say(reply) => Some(Frame::TtsSpeak {
152 text: reply.clone(),
153 append_to_context: Some(false),
154 }),
155 IvrAction::Wait => None,
156 }
157 }
158}
159
160#[async_trait]
161impl FrameProcessor for IvrNavigator {
162 fn name(&self) -> &str {
163 self.name
164 }
165
166 async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
167 let Frame::Transcription { ref text, .. } = env.frame else {
168 link.push(env.meta, env.frame, env.direction).await;
169 return Ok(());
170 };
171
172 let direction = env.direction;
174 let prompt_lower = text.to_lowercase();
175 link.push(env.meta, env.frame, direction).await;
176
177 if self.finished {
178 return Ok(());
179 }
180
181 if let Some(step) = self.script.steps.get(self.cursor) {
184 if step.matches(&prompt_lower) {
185 self.unmatched_streak = 0;
186 if let Some(frame) = Self::action_frame(&step.action) {
187 link.push_down(frame).await;
188 }
189 self.cursor += 1;
190 if self.cursor >= self.script.steps.len() {
191 self.finished = true;
192 link.push_down(Frame::Custom(Arc::new(IvrStatusChanged {
193 status: IvrStatus::Completed,
194 })))
195 .await;
196 }
197 return Ok(());
198 }
199 }
200
201 self.unmatched_streak += 1;
203 if self.script.stuck_after > 0 && self.unmatched_streak >= self.script.stuck_after {
204 self.finished = true;
205 link.push_down(Frame::Custom(Arc::new(IvrStatusChanged {
206 status: IvrStatus::Stuck,
207 })))
208 .await;
209 }
210 Ok(())
211 }
212}
213
214pub fn as_ivr_status(frame: &Frame) -> Option<&IvrStatusChanged> {
216 match frame {
217 Frame::Custom(c) => c.as_any().downcast_ref::<IvrStatusChanged>(),
218 _ => None,
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225 use crate::agent::test_harness::drive;
226 use crate::processor::frame::Direction;
227
228 fn prompt(text: &str) -> Frame {
229 Frame::Transcription {
230 text: text.into(),
231 user_id: Arc::from("ivr"),
232 language: None,
233 final_: true,
234 }
235 }
236
237 fn dtmf_of(frame: &Frame) -> Option<Vec<KeypadEntry>> {
238 match frame {
239 Frame::OutputDtmf(d) => Some(d.clone()),
240 _ => None,
241 }
242 }
243
244 #[tokio::test]
245 async fn navigates_a_menu_path_and_completes() {
246 let script = Script::new(
249 vec![
250 IvrStep::press(
251 &["press 2 for billing", "for billing press 2"],
252 vec![KeypadEntry::Two],
253 ),
254 IvrStep::press(&["enter your account number"], vec![KeypadEntry::One]),
255 IvrStep::say(&["say yes or no", "is this correct"], "Yes."),
256 ],
257 3,
258 );
259 let out = drive(
260 Box::new(IvrNavigator::new(script)),
261 vec![
262 prompt("Thank you for calling. Press 1 for sales, press 2 for billing."),
263 prompt("Please enter your account number followed by pound."),
264 prompt("I heard account one. Is this correct? Say yes or no."),
265 ],
266 Direction::Downstream,
267 )
268 .await;
269
270 let dtmfs: Vec<Vec<KeypadEntry>> = out.iter().filter_map(dtmf_of).collect();
272 assert_eq!(dtmfs, vec![vec![KeypadEntry::Two], vec![KeypadEntry::One]]);
273 let said = out
275 .iter()
276 .any(|f| matches!(f, Frame::TtsSpeak { text, .. } if text == "Yes."));
277 assert!(said, "verbal step did not speak: {out:?}");
278 let status = out.iter().find_map(as_ivr_status).expect("no status");
280 assert_eq!(status.status, IvrStatus::Completed);
281 }
282
283 #[tokio::test]
284 async fn waits_through_irrelevant_prompts_then_matches() {
285 let script = Script::new(
286 vec![IvrStep::press(
287 &["press 0 for an agent"],
288 vec![KeypadEntry::Zero],
289 )],
290 5,
291 );
292 let out = drive(
293 Box::new(IvrNavigator::new(script)),
294 vec![
295 prompt("Your call is important to us."), prompt("Please continue to hold."), prompt("Or press 0 for an agent."), ],
299 Direction::Downstream,
300 )
301 .await;
302 let dtmfs: Vec<Vec<KeypadEntry>> = out.iter().filter_map(dtmf_of).collect();
303 assert_eq!(dtmfs, vec![vec![KeypadEntry::Zero]]);
304 let status = out.iter().find_map(as_ivr_status).expect("no status");
305 assert_eq!(status.status, IvrStatus::Completed);
306 }
307
308 #[tokio::test]
309 async fn reports_stuck_after_unmatched_streak() {
310 let script = Script::new(
311 vec![IvrStep::press(
312 &["press 9 for spanish"],
313 vec![KeypadEntry::Nine],
314 )],
315 2, );
317 let out = drive(
318 Box::new(IvrNavigator::new(script)),
319 vec![
320 prompt("Welcome to the help line."), prompt("All agents are busy."), prompt("press 9 for spanish"), ],
324 Direction::Downstream,
325 )
326 .await;
327 let status = out.iter().find_map(as_ivr_status).expect("no status");
328 assert_eq!(status.status, IvrStatus::Stuck);
329 assert!(out.iter().filter_map(dtmf_of).next().is_none());
331 }
332
333 #[tokio::test]
334 async fn non_transcription_frames_pass_through() {
335 let script = Script::new(vec![IvrStep::press(&["x"], vec![KeypadEntry::One])], 3);
336 let out = drive(
337 Box::new(IvrNavigator::new(script)),
338 vec![Frame::Text("hi".into())],
339 Direction::Downstream,
340 )
341 .await;
342 assert!(matches!(out.first(), Some(Frame::Text(t)) if t == "hi"));
343 }
344}