Skip to main content

active_call/playbook/
runner.rs

1use crate::CallOption;
2use crate::call::{ActiveCallRef, ActiveCallType, Command};
3use crate::event::EventReceiver;
4use anyhow::{Result, anyhow};
5use serde_json::json;
6use std::time::Duration;
7use tracing::{error, info, warn};
8
9use super::{Playbook, PlaybookConfig, dialogue::DialogueHandler, handler::LlmHandler};
10
11pub struct PlaybookRunner {
12    handler: Box<dyn DialogueHandler>,
13    call: ActiveCallRef,
14    config: PlaybookConfig,
15    event_receiver: EventReceiver,
16}
17
18impl PlaybookRunner {
19    pub fn with_handler(
20        handler: Box<dyn DialogueHandler>,
21        call: ActiveCallRef,
22        config: PlaybookConfig,
23    ) -> Self {
24        let event_receiver = call.event_sender.subscribe();
25        Self {
26            handler,
27            call,
28            config,
29            event_receiver,
30        }
31    }
32
33    pub fn new(playbook: Playbook, call: ActiveCallRef) -> Result<Self> {
34        let event_receiver = call.event_sender.subscribe();
35        if let Ok(mut state) = call.call_state.try_write() {
36            // Ensure option exists before applying config
37            if state.option.is_none() {
38                state.option = Some(CallOption::default());
39            }
40            if let Some(option) = state.option.as_mut() {
41                apply_playbook_config(option, &playbook.config);
42            }
43        }
44
45        let handler: Box<dyn DialogueHandler> = if let Some(llm_config) = &playbook.config.llm {
46            let mut llm_config = llm_config.clone();
47            if let Some(greeting) = playbook.config.greeting.clone() {
48                llm_config.greeting = Some(greeting);
49            }
50            let interruption_config = playbook.config.interruption.clone().unwrap_or_default();
51            let dtmf_config = playbook.config.dtmf.clone();
52            let dtmf_collectors = playbook.config.dtmf_collectors.clone();
53
54            let mut llm_handler = LlmHandler::new(
55                llm_config,
56                interruption_config,
57                playbook.config.follow_up,
58                playbook.scenes.clone(),
59                dtmf_config,
60                dtmf_collectors,
61                playbook.initial_scene_id.clone(),
62                playbook.config.sip.clone(),
63            );
64            // Set event sender for debugging
65            llm_handler.set_event_sender(call.event_sender.clone());
66            llm_handler.set_call(call.clone());
67            Box::new(llm_handler)
68        } else {
69            return Err(anyhow!(
70                "No valid dialogue handler configuration found (e.g. missing 'llm')"
71            ));
72        };
73
74        Ok(Self {
75            handler,
76            call,
77            config: playbook.config,
78            event_receiver,
79        })
80    }
81
82    pub async fn run(mut self) {
83        info!(
84            "PlaybookRunner started for session {}",
85            self.call.session_id
86        );
87
88        let mut answered = {
89            let state = self.call.call_state.read().await;
90            state.answer_time.is_some()
91        };
92        let wait_for_media_ready =
93            matches!(self.call.call_type, ActiveCallType::Sip | ActiveCallType::B2bua);
94        let mut media_ready = !wait_for_media_ready;
95
96        if let Ok(commands) = self.handler.on_start().await {
97            for cmd in commands {
98                let is_media = matches!(cmd, Command::Tts { .. } | Command::Play { .. });
99
100                if is_media && (!answered || !media_ready) {
101                    info!(
102                        wait_for_media_ready,
103                        "Waiting for call media readiness before executing media command..."
104                    );
105                    while let Ok(event) = self.event_receiver.recv().await {
106                        match &event {
107                            crate::event::SessionEvent::Answer { .. } => {
108                                info!("Call established");
109                                answered = true;
110                            }
111                            crate::event::SessionEvent::MediaReady { .. } => {
112                                info!("Call media ready");
113                                media_ready = true;
114                            }
115                            crate::event::SessionEvent::Hangup { .. } => {
116                                info!("Call hung up before media command, stopping");
117                                return;
118                            }
119                            _ => {}
120                        }
121                        if answered && media_ready {
122                            info!("Proceeding to execute media command");
123                            break;
124                        }
125                    }
126                }
127
128                if let Err(e) = self.call.enqueue_command(cmd).await {
129                    error!("Failed to enqueue start command: {}", e);
130                }
131            }
132        }
133
134        if !answered {
135            info!("Waiting for call establishment...");
136            while let Ok(event) = self.event_receiver.recv().await {
137                match &event {
138                    crate::event::SessionEvent::Answer { .. } => {
139                        info!("Call established, proceeding to playbook handles");
140                        break;
141                    }
142                    crate::event::SessionEvent::Hangup { .. } => {
143                        info!("Call hung up before established, stopping");
144                        return;
145                    }
146                    _ => {}
147                }
148            }
149        }
150
151        while let Ok(event) = self.event_receiver.recv().await {
152            if let Ok(commands) = self.handler.on_event(&event).await {
153                for cmd in commands {
154                    if let Err(e) = self.call.enqueue_command(cmd).await {
155                        error!("Failed to enqueue command: {}", e);
156                    }
157                }
158            }
159            match &event {
160                crate::event::SessionEvent::Hangup { .. } => {
161                    info!("Call hung up, stopping playbook");
162                    break;
163                }
164                _ => {}
165            }
166        }
167
168        // Post-hook logic
169        if let Some(posthook) = self.config.posthook.clone() {
170            let mut handler = self.handler;
171            let session_id = self.call.session_id.clone();
172            // Drop the ActiveCallRef before spawning to avoid keeping the entire call alive
173            drop(self.call);
174            crate::spawn(async move {
175                info!("Executing posthook for session {}", session_id);
176
177                let posthook_timeout = Duration::from_secs(
178                    posthook.timeout.unwrap_or(30) as u64
179                );
180
181                let posthook_task = async {
182                    let summary = if let Some(summary_type) = &posthook.summary {
183                        match handler.summarize(summary_type.prompt()).await {
184                            Ok(s) => Some(s),
185                            Err(e) => {
186                                error!("Failed to generate summary: {}", e);
187                                None
188                            }
189                        }
190                    } else {
191                        None
192                    };
193
194                    let history = if posthook.include_history.unwrap_or(true) {
195                        Some(handler.get_history().await)
196                    } else {
197                        None
198                    };
199
200                    let payload = json!({
201                        "sessionId": session_id,
202                        "summary": summary,
203                        "history": history,
204                        "timestamp": chrono::Utc::now().to_rfc3339(),
205                    });
206
207                    let client = reqwest::Client::new();
208                    let method = posthook
209                        .method
210                        .as_deref()
211                        .unwrap_or("POST")
212                        .parse::<reqwest::Method>()
213                        .unwrap_or(reqwest::Method::POST);
214
215                    let mut request = client.request(method, &posthook.url).json(&payload);
216
217                    if let Some(headers) = posthook.headers {
218                        for (k, v) in headers {
219                            request = request.header(k, v);
220                        }
221                    }
222
223                    match request.send().await {
224                        Ok(resp) => {
225                            if resp.status().is_success() {
226                                info!("Posthook sent successfully");
227                            } else {
228                                warn!("Posthook failed with status: {}", resp.status());
229                            }
230                        }
231                        Err(e) => {
232                            error!("Failed to send posthook: {}", e);
233                        }
234                    }
235                };
236
237                if tokio::time::timeout(posthook_timeout, posthook_task).await.is_err() {
238                    error!("Posthook timed out for session {}", session_id);
239                }
240            });
241        }
242    }
243}
244
245pub fn apply_playbook_config(option: &mut CallOption, config: &PlaybookConfig) {
246    let api_key = config.llm.as_ref().and_then(|llm| llm.api_key.clone());
247
248    if let Some(mut asr) = config.asr.clone() {
249        if asr.secret_key.is_none() {
250            asr.secret_key = api_key.clone();
251        }
252        option.asr = Some(asr);
253    }
254    if let Some(mut tts) = config.tts.clone() {
255        if tts.secret_key.is_none() {
256            tts.secret_key = api_key.clone();
257        }
258        option.tts = Some(tts);
259    }
260    if let Some(vad) = config.vad.clone() {
261        option.vad = Some(vad);
262    }
263    if let Some(denoise) = config.denoise {
264        option.denoise = Some(denoise);
265    }
266    if let Some(agc) = config.agc.clone() {
267        option.agc = Some(agc);
268    }
269    if let Some(ambiance) = config.ambiance.clone() {
270        option.ambiance = Some(ambiance);
271    }
272    if let Some(recorder) = config.recorder.clone() {
273        option.recorder = Some(recorder);
274    }
275    if let Some(extra) = config.extra.clone() {
276        option.extra = Some(extra);
277    }
278    if let Some(mut realtime) = config.realtime.clone() {
279        if realtime.secret_key.is_none() {
280            realtime.secret_key = api_key.clone();
281        }
282        option.realtime = Some(realtime);
283    }
284    if let Some(mut eou) = config.eou.clone() {
285        if eou.secret_key.is_none() {
286            eou.secret_key = api_key;
287        }
288        option.eou = Some(eou);
289    }
290    if let Some(sip) = config.sip.clone() {
291        option.sip = Some(sip);
292    }
293    if let Some(ringback) = config.ringback_detection.clone() {
294        option.ringback_detection = Some(ringback);
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use crate::{
302        EouOption, media::recorder::RecorderOption, media::vad::VADOption,
303        synthesis::SynthesisOption, transcription::TranscriptionOption,
304    };
305    use std::collections::HashMap;
306
307    #[test]
308    fn apply_playbook_config_sets_fields() {
309        let mut option = CallOption::default();
310        let mut extra = HashMap::new();
311        extra.insert("k".to_string(), "v".to_string());
312
313        let config = PlaybookConfig {
314            asr: Some(TranscriptionOption::default()),
315            tts: Some(SynthesisOption::default()),
316            vad: Some(VADOption::default()),
317            denoise: Some(true),
318            recorder: Some(RecorderOption::default()),
319            extra: Some(extra.clone()),
320            eou: Some(EouOption {
321                r#type: Some("test".to_string()),
322                endpoint: None,
323                secret_key: Some("key".to_string()),
324                secret_id: Some("id".to_string()),
325                timeout: Some(123),
326                extra: None,
327            }),
328            ..Default::default()
329        };
330
331        apply_playbook_config(&mut option, &config);
332
333        assert!(option.asr.is_some());
334        assert!(option.tts.is_some());
335        assert!(option.vad.is_some());
336        assert_eq!(option.denoise, Some(true));
337        assert!(option.recorder.is_some());
338        assert_eq!(option.extra, Some(extra));
339        assert!(option.eou.is_some());
340    }
341
342    #[test]
343    fn apply_playbook_config_propagates_api_key() {
344        let mut option = CallOption::default();
345        let config = PlaybookConfig {
346            llm: Some(super::super::LlmConfig {
347                api_key: Some("test-key".to_string()),
348                ..Default::default()
349            }),
350            asr: Some(TranscriptionOption::default()),
351            tts: Some(SynthesisOption::default()),
352            eou: Some(EouOption::default()),
353            ..Default::default()
354        };
355
356        apply_playbook_config(&mut option, &config);
357
358        assert_eq!(
359            option.asr.as_ref().unwrap().secret_key,
360            Some("test-key".to_string())
361        );
362        assert_eq!(
363            option.tts.as_ref().unwrap().secret_key,
364            Some("test-key".to_string())
365        );
366        assert_eq!(
367            option.eou.as_ref().unwrap().secret_key,
368            Some("test-key".to_string())
369        );
370    }
371
372    #[test]
373    fn posthook_config_timeout_default() {
374        use crate::playbook::PostHookConfig;
375
376        // Test default timeout (None -> should use 30 in code)
377        let config = PostHookConfig {
378            url: "http://example.com".to_string(),
379            ..Default::default()
380        };
381        assert_eq!(config.timeout, None);
382        // Code uses unwrap_or(30), verify the logic
383        assert_eq!(config.timeout.unwrap_or(30), 30);
384
385        // Test custom timeout
386        let config = PostHookConfig {
387            url: "http://example.com".to_string(),
388            timeout: Some(60),
389            ..Default::default()
390        };
391        assert_eq!(config.timeout, Some(60));
392        assert_eq!(config.timeout.unwrap_or(30), 60);
393    }
394
395    #[test]
396    fn posthook_config_serde_with_timeout() {
397        use crate::playbook::PostHookConfig;
398
399        // Test that timeout field is correctly serialized/deserialized
400        let json = r#"{"url": "http://example.com", "timeout": 45}"#;
401        let config: PostHookConfig = serde_json::from_str(json).unwrap();
402        assert_eq!(config.timeout, Some(45));
403        assert_eq!(config.url, "http://example.com");
404
405        // Without timeout
406        let json = r#"{"url": "http://example.com"}"#;
407        let config: PostHookConfig = serde_json::from_str(json).unwrap();
408        assert_eq!(config.timeout, None);
409    }
410}