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 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 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 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(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 let config = PostHookConfig {
378 url: "http://example.com".to_string(),
379 ..Default::default()
380 };
381 assert_eq!(config.timeout, None);
382 assert_eq!(config.timeout.unwrap_or(30), 30);
384
385 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 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 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}