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