active_call/playbook/
runner.rs1use 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 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 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 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 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}