Skip to main content

voice_echo/api/
outbound.rs

1use axum::extract::State;
2use axum::http::{HeaderMap, StatusCode};
3use axum::response::IntoResponse;
4use axum::Json;
5use serde::{Deserialize, Serialize};
6
7use crate::{AppState, CallMeta};
8
9#[derive(Debug, Deserialize)]
10pub struct CallRequest {
11    /// Phone number to call (E.164 format, e.g., "+34612345678")
12    pub to: String,
13    /// Deprecated — kept for API backward compatibility. No longer used.
14    #[allow(dead_code)]
15    pub message: Option<String>,
16    /// Optional context for the AI — why this call is being made.
17    /// Injected into the first Claude prompt so it knows the reason for calling.
18    pub context: Option<String>,
19    /// Short reason for calling, used in the outbound greeting.
20    /// e.g., "I found something interesting in the logs"
21    pub reason: Option<String>,
22}
23
24#[derive(Debug, Serialize)]
25pub struct CallResponse {
26    pub call_sid: String,
27    pub status: String,
28}
29
30#[derive(Debug, Serialize)]
31struct ErrorResponse {
32    error: String,
33}
34
35/// POST /api/call — Trigger an outbound call.
36///
37/// Requires `Authorization: Bearer <token>` header matching the configured api.token.
38///
39/// Request body:
40/// ```json
41/// {
42///   "to": "+34612345678",
43///   "message": "Server CPU at 95%"
44/// }
45/// ```
46pub async fn handle_call(
47    State(state): State<AppState>,
48    headers: HeaderMap,
49    Json(req): Json<CallRequest>,
50) -> impl IntoResponse {
51    // Check bearer token
52    if let Err(resp) = check_auth(&headers, &state.config.api.token) {
53        return resp;
54    }
55
56    tracing::info!(to = %req.to, "Outbound call requested");
57
58    match state.twilio.call(&req.to).await {
59        Ok(call_sid) => {
60            // Store call metadata (context + reason) for this call
61            if req.context.is_some() || req.reason.is_some() {
62                state.call_metas.lock().await.insert(
63                    call_sid.clone(),
64                    CallMeta {
65                        context: req.context,
66                        reason: req.reason,
67                    },
68                );
69                tracing::info!(call_sid = %call_sid, "Stored call metadata");
70            }
71            (
72                StatusCode::OK,
73                Json(CallResponse {
74                    call_sid,
75                    status: "initiated".to_string(),
76                }),
77            )
78                .into_response()
79        }
80        Err(e) => {
81            tracing::error!("Failed to initiate call: {e}");
82            (
83                StatusCode::INTERNAL_SERVER_ERROR,
84                Json(ErrorResponse {
85                    error: e.to_string(),
86                }),
87            )
88                .into_response()
89        }
90    }
91}
92
93#[allow(clippy::result_large_err)]
94pub fn check_auth(
95    headers: &HeaderMap,
96    expected_token: &str,
97) -> Result<(), axum::response::Response> {
98    if expected_token.is_empty() {
99        tracing::warn!("API token not configured — rejecting request");
100        return Err((
101            StatusCode::SERVICE_UNAVAILABLE,
102            Json(ErrorResponse {
103                error: "API token not configured".to_string(),
104            }),
105        )
106            .into_response());
107    }
108
109    let provided = headers
110        .get("authorization")
111        .and_then(|v| v.to_str().ok())
112        .and_then(|v| v.strip_prefix("Bearer "));
113
114    match provided {
115        Some(token) if token == expected_token => Ok(()),
116        _ => {
117            tracing::warn!("Unauthorized API request");
118            Err((
119                StatusCode::UNAUTHORIZED,
120                Json(ErrorResponse {
121                    error: "Invalid or missing bearer token".to_string(),
122                }),
123            )
124                .into_response())
125        }
126    }
127}