active_call/playbook/
runner.rs

1use crate::CallOption;
2use crate::call::ActiveCallRef;
3use anyhow::{Result, anyhow};
4use serde_json::json;
5use tracing::{error, info, warn};
6
7use super::{Playbook, PlaybookConfig, dialogue::DialogueHandler, handler::LlmHandler};
8
9pub struct PlaybookRunner {
10    handler: Box<dyn DialogueHandler>,
11    call: ActiveCallRef,
12    config: PlaybookConfig,
13}
14
15impl PlaybookRunner {
16    pub fn with_handler(
17        handler: Box<dyn DialogueHandler>,
18        call: ActiveCallRef,
19        config: PlaybookConfig,
20    ) -> Self {
21        Self {
22            handler,
23            call,
24            config,
25        }
26    }
27
28    pub fn new(playbook: Playbook, call: ActiveCallRef) -> Result<Self> {
29        if let Ok(mut state) = call.call_state.try_write() {
30            // Ensure option exists before applying config
31            if state.option.is_none() {
32                state.option = Some(CallOption::default());
33            }
34            if let Some(option) = state.option.as_mut() {
35                apply_playbook_config(option, &playbook.config);
36            }
37        }
38
39        let handler: Box<dyn DialogueHandler> = if let Some(llm_config) = &playbook.config.llm {
40            let mut llm_config = llm_config.clone();
41            if let Some(greeting) = playbook.config.greeting.clone() {
42                llm_config.greeting = Some(greeting);
43            }
44            let interruption_config = playbook.config.interruption.clone().unwrap_or_default();
45            let dtmf_config = playbook.config.dtmf.clone();
46
47            let mut llm_handler = LlmHandler::new(
48                llm_config,
49                interruption_config,
50                playbook.config.follow_up,
51                playbook.scenes.clone(),
52                dtmf_config,
53                playbook.initial_scene_id.clone(),
54            );
55            // Set event sender for debugging
56            llm_handler.set_event_sender(call.event_sender.clone());
57            llm_handler.set_call(call.clone());
58            Box::new(llm_handler)
59        } else {
60            return Err(anyhow!(
61                "No valid dialogue handler configuration found (e.g. missing 'llm')"
62            ));
63        };
64
65        Ok(Self {
66            handler,
67            call,
68            config: playbook.config,
69        })
70    }
71
72    pub async fn run(mut self) {
73        info!(
74            "PlaybookRunner started for session {}",
75            self.call.session_id
76        );
77
78        let mut event_receiver = self.call.event_sender.subscribe();
79
80        if let Ok(commands) = self.handler.on_start().await {
81            for cmd in commands {
82                if let Err(e) = self.call.enqueue_command(cmd).await {
83                    error!("Failed to enqueue start command: {}", e);
84                }
85            }
86        }
87
88        // Wait for call to be established before running playbook greeting
89        let mut answered = false;
90        {
91            let state = self.call.call_state.read().await;
92            if state.answer_time.is_some() {
93                answered = true;
94            }
95        }
96
97        if !answered {
98            info!("Waiting for call establishment...");
99            while let Ok(event) = event_receiver.recv().await {
100                match &event {
101                    crate::event::SessionEvent::Answer { .. } => {
102                        info!("Call established, proceeding to playbook handles");
103                        break;
104                    }
105                    crate::event::SessionEvent::Hangup { .. } => {
106                        info!("Call hung up before established, stopping");
107                        return;
108                    }
109                    _ => {}
110                }
111            }
112        }
113
114        while let Ok(event) = event_receiver.recv().await {
115            match &event {
116                crate::event::SessionEvent::AsrFinal { text, .. } => {
117                    info!("User said: {}", text);
118                }
119                crate::event::SessionEvent::Hangup { .. } => {
120                    info!("Call hung up, stopping playbook");
121                    break;
122                }
123                _ => {}
124            }
125
126            if let Ok(commands) = self.handler.on_event(&event).await {
127                for cmd in commands {
128                    if let Err(e) = self.call.enqueue_command(cmd).await {
129                        error!("Failed to enqueue command: {}", e);
130                    }
131                }
132            }
133        }
134
135        // Post-hook logic
136        if let Some(posthook) = self.config.posthook.clone() {
137            let mut handler = self.handler;
138            let call = self.call.clone();
139            crate::spawn(async move {
140                info!("Executing posthook for session {}", call.session_id);
141
142                let summary = if let Some(summary_type) = &posthook.summary {
143                    match handler.summarize(summary_type.prompt()).await {
144                        Ok(s) => Some(s),
145                        Err(e) => {
146                            error!("Failed to generate summary: {}", e);
147                            None
148                        }
149                    }
150                } else {
151                    None
152                };
153
154                let history = if posthook.include_history.unwrap_or(true) {
155                    Some(handler.get_history().await)
156                } else {
157                    None
158                };
159
160                let payload = json!({
161                    "sessionId": call.session_id,
162                    "summary": summary,
163                    "history": history,
164                    "timestamp": chrono::Utc::now().to_rfc3339(),
165                });
166
167                let client = reqwest::Client::new();
168                let method = posthook
169                    .method
170                    .as_deref()
171                    .unwrap_or("POST")
172                    .parse::<reqwest::Method>()
173                    .unwrap_or(reqwest::Method::POST);
174
175                let mut request = client.request(method, &posthook.url).json(&payload);
176
177                if let Some(headers) = posthook.headers {
178                    for (k, v) in headers {
179                        request = request.header(k, v);
180                    }
181                }
182
183                match request.send().await {
184                    Ok(resp) => {
185                        if resp.status().is_success() {
186                            info!("Posthook sent successfully");
187                        } else {
188                            warn!("Posthook failed with status: {}", resp.status());
189                        }
190                    }
191                    Err(e) => {
192                        error!("Failed to send posthook: {}", e);
193                    }
194                }
195            });
196        }
197    }
198}
199
200pub fn apply_playbook_config(option: &mut CallOption, config: &PlaybookConfig) {
201    let api_key = config.llm.as_ref().and_then(|llm| llm.api_key.clone());
202
203    if let Some(mut asr) = config.asr.clone() {
204        if asr.secret_key.is_none() {
205            asr.secret_key = api_key.clone();
206        }
207        option.asr = Some(asr);
208    }
209    if let Some(mut tts) = config.tts.clone() {
210        if tts.secret_key.is_none() {
211            tts.secret_key = api_key.clone();
212        }
213        option.tts = Some(tts);
214    }
215    if let Some(vad) = config.vad.clone() {
216        option.vad = Some(vad);
217    }
218    if let Some(denoise) = config.denoise {
219        option.denoise = Some(denoise);
220    }
221    if let Some(ambiance) = config.ambiance.clone() {
222        option.ambiance = Some(ambiance);
223    }
224    if let Some(recorder) = config.recorder.clone() {
225        option.recorder = Some(recorder);
226    }
227    if let Some(extra) = config.extra.clone() {
228        option.extra = Some(extra);
229    }
230    if let Some(mut realtime) = config.realtime.clone() {
231        if realtime.secret_key.is_none() {
232            realtime.secret_key = api_key.clone();
233        }
234        option.realtime = Some(realtime);
235    }
236    if let Some(mut eou) = config.eou.clone() {
237        if eou.secret_key.is_none() {
238            eou.secret_key = api_key;
239        }
240        option.eou = Some(eou);
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::{
248        EouOption, media::recorder::RecorderOption, media::vad::VADOption,
249        synthesis::SynthesisOption, transcription::TranscriptionOption,
250    };
251    use std::collections::HashMap;
252
253    #[test]
254    fn apply_playbook_config_sets_fields() {
255        let mut option = CallOption::default();
256        let mut extra = HashMap::new();
257        extra.insert("k".to_string(), "v".to_string());
258
259        let config = PlaybookConfig {
260            asr: Some(TranscriptionOption::default()),
261            tts: Some(SynthesisOption::default()),
262            vad: Some(VADOption::default()),
263            denoise: Some(true),
264            recorder: Some(RecorderOption::default()),
265            extra: Some(extra.clone()),
266            eou: Some(EouOption {
267                r#type: Some("test".to_string()),
268                endpoint: None,
269                secret_key: Some("key".to_string()),
270                secret_id: Some("id".to_string()),
271                timeout: Some(123),
272                extra: None,
273            }),
274            ..Default::default()
275        };
276
277        apply_playbook_config(&mut option, &config);
278
279        assert!(option.asr.is_some());
280        assert!(option.tts.is_some());
281        assert!(option.vad.is_some());
282        assert_eq!(option.denoise, Some(true));
283        assert!(option.recorder.is_some());
284        assert_eq!(option.extra, Some(extra));
285        assert!(option.eou.is_some());
286    }
287
288    #[test]
289    fn apply_playbook_config_propagates_api_key() {
290        let mut option = CallOption::default();
291        let config = PlaybookConfig {
292            llm: Some(super::super::LlmConfig {
293                api_key: Some("test-key".to_string()),
294                ..Default::default()
295            }),
296            asr: Some(TranscriptionOption::default()),
297            tts: Some(SynthesisOption::default()),
298            eou: Some(EouOption::default()),
299            ..Default::default()
300        };
301
302        apply_playbook_config(&mut option, &config);
303
304        assert_eq!(
305            option.asr.as_ref().unwrap().secret_key,
306            Some("test-key".to_string())
307        );
308        assert_eq!(
309            option.tts.as_ref().unwrap().secret_key,
310            Some("test-key".to_string())
311        );
312        assert_eq!(
313            option.eou.as_ref().unwrap().secret_key,
314            Some("test-key".to_string())
315        );
316    }
317}