active_call/handler/
playbook.rs

1use crate::app::AppState;
2use axum::{
3    extract::{Path, State},
4    http::StatusCode,
5    response::{IntoResponse, Json},
6};
7use serde::{Deserialize, Serialize};
8use std::fs;
9use std::path::PathBuf;
10use uuid::Uuid;
11
12#[derive(Deserialize)]
13#[serde(untagged)]
14pub enum PlaybookSource {
15    File { playbook: String },
16    Content { content: String },
17}
18
19#[derive(Deserialize)]
20pub struct RunPlaybookParams {
21    #[serde(flatten)]
22    pub source: PlaybookSource,
23    pub r#type: Option<String>,
24    pub to: Option<String>,
25}
26
27#[derive(Serialize)]
28pub struct RunPlaybookResponse {
29    pub session_id: String,
30}
31
32#[derive(Serialize)]
33pub struct PlaybookInfo {
34    name: String,
35    updated: String,
36}
37
38#[derive(Serialize)]
39pub struct RecordInfo {
40    id: String,
41    date: String,
42    duration: String,
43    status: String,
44}
45
46pub async fn list_playbooks() -> impl IntoResponse {
47    let mut playbooks = Vec::new();
48    let path = PathBuf::from("config/playbook");
49
50    if let Ok(entries) = fs::read_dir(path) {
51        for entry in entries.flatten() {
52            if let Ok(metadata) = entry.metadata() {
53                if metadata.is_file() {
54                    if let Some(name) = entry.file_name().to_str() {
55                        if name.ends_with(".md") {
56                            let updated = metadata
57                                .modified()
58                                .ok()
59                                .map(|t| chrono::DateTime::<chrono::Utc>::from(t).to_rfc3339())
60                                .unwrap_or_default();
61
62                            playbooks.push(PlaybookInfo {
63                                name: name.to_string(),
64                                updated,
65                            });
66                        }
67                    }
68                }
69            }
70        }
71    }
72
73    Json(playbooks)
74}
75
76pub async fn get_playbook(Path(name): Path<String>) -> impl IntoResponse {
77    let path = PathBuf::from("config/playbook").join(&name);
78
79    // Security check: prevent directory traversal
80    if name.contains("..") || name.contains('/') || name.contains('\\') {
81        return (StatusCode::BAD_REQUEST, "Invalid filename").into_response();
82    }
83
84    match fs::read_to_string(path) {
85        Ok(content) => content.into_response(),
86        Err(_) => (StatusCode::NOT_FOUND, "Playbook not found").into_response(),
87    }
88}
89
90pub async fn save_playbook(Path(name): Path<String>, body: String) -> impl IntoResponse {
91    let path = PathBuf::from("config/playbook").join(&name);
92
93    if name.contains("..") || name.contains('/') || name.contains('\\') {
94        return (StatusCode::BAD_REQUEST, "Invalid filename").into_response();
95    }
96
97    // Ensure directory exists
98    if let Some(parent) = path.parent() {
99        let _ = fs::create_dir_all(parent);
100    }
101
102    match fs::write(path, body) {
103        Ok(_) => StatusCode::OK.into_response(),
104        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
105    }
106}
107
108pub async fn list_records(State(state): State<AppState>) -> impl IntoResponse {
109    let mut records = Vec::new();
110    let path = PathBuf::from(state.config.recorder_path());
111
112    if let Ok(entries) = fs::read_dir(path) {
113        for entry in entries.flatten() {
114            if let Ok(metadata) = entry.metadata() {
115                if metadata.is_file() {
116                    if let Some(name) = entry.file_name().to_str() {
117                        if name.ends_with(".jsonl") {
118                            let updated = metadata
119                                .modified()
120                                .ok()
121                                .map(|t| chrono::DateTime::<chrono::Utc>::from(t).to_rfc3339())
122                                .unwrap_or_default();
123
124                            // In a real app, we'd parse the file to get duration/status
125                            // For now, just return file info
126                            records.push(RecordInfo {
127                                id: name.replace(".events.jsonl", ""),
128                                date: updated,
129                                duration: "0s".to_string(), // Placeholder
130                                status: "completed".to_string(), // Placeholder
131                            });
132                        }
133                    }
134                }
135            }
136        }
137    }
138
139    // Sort by date desc
140    records.sort_by(|a, b| b.date.cmp(&a.date));
141
142    Json(records)
143}
144
145pub async fn run_playbook(
146    State(state): State<AppState>,
147    Json(params): Json<RunPlaybookParams>,
148) -> impl IntoResponse {
149    let playbook_val = match params.source {
150        PlaybookSource::File { playbook } => playbook,
151        PlaybookSource::Content { content } => content,
152    };
153
154    let session_id = format!("s.{}", Uuid::new_v4().to_string());
155
156    // Store pending playbook
157    state
158        .pending_playbooks
159        .lock()
160        .await
161        .insert(session_id.clone(), playbook_val);
162
163    // TODO: Handle SIP outbound if needed
164
165    Json(RunPlaybookResponse { session_id }).into_response()
166}