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 call.progress.rcu(|p| {
37 let mut p = crate::call::state::CallProgress::clone(p);
38 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 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 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(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 let config = PostHookConfig {
377 url: "http://example.com".to_string(),
378 ..Default::default()
379 };
380 assert_eq!(config.timeout, None);
381 assert_eq!(config.timeout.unwrap_or(30), 30);
383
384 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 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 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}