Skip to main content

tryaudex_core/
notify.rs

1use crate::error::{AvError, Result};
2use crate::session::Session;
3
4/// Supported notification targets.
5pub enum NotifyTarget {
6    Slack(String),
7    Discord(String),
8    Generic(String),
9}
10
11impl NotifyTarget {
12    /// Parse a notification URL like "slack://webhook-url" or a plain HTTPS URL.
13    pub fn parse(url: &str) -> Result<Self> {
14        if let Some(webhook) = url.strip_prefix("slack://") {
15            Ok(Self::Slack(webhook.to_string()))
16        } else if let Some(webhook) = url.strip_prefix("discord://") {
17            Ok(Self::Discord(webhook.to_string()))
18        } else if url.starts_with("https://") {
19            Ok(Self::Generic(url.to_string()))
20        } else if url.starts_with("http://") {
21            Err(AvError::InvalidPolicy(
22                "Refusing plaintext HTTP for notifications — session metadata would be sent in cleartext. Use https://".to_string()
23            ))
24        } else {
25            Err(AvError::InvalidPolicy(format!(
26                "Invalid notify URL '{}'. Use slack://WEBHOOK_URL, discord://WEBHOOK_URL, or an HTTPS URL",
27                url
28            )))
29        }
30    }
31}
32
33/// Send a session start notification.
34pub async fn notify_start(target: &NotifyTarget, session: &Session) -> Result<()> {
35    let actions: Vec<String> = session
36        .policy
37        .actions
38        .iter()
39        .map(|a| a.to_iam_action())
40        .collect();
41    let text = format!(
42        "Audex session started\n\u{2022} Session: `{}`\n\u{2022} Role: `{}`\n\u{2022} Actions: `{}`\n\u{2022} Command: `{}`\n\u{2022} TTL: {}s",
43        session.short_id(),
44        session.role_arn,
45        actions.join(", "),
46        session.command.join(" "),
47        session.ttl_seconds,
48    );
49    send(target, &text).await
50}
51
52/// Send a budget-alert notification when orphaned resources exceed a cost
53/// threshold. `total_daily_cost` is the aggregate from
54/// `cleanup::estimate_daily_cost_total` across every orphan session, and
55/// `usage_dependent` indicates the total is a floor rather than an exact
56/// figure (so we mark the alert as "floor" in that case).
57pub async fn notify_orphan_budget_alert(
58    target: &NotifyTarget,
59    threshold_usd_per_day: f64,
60    total_daily_cost: f64,
61    usage_dependent: bool,
62    orphan_session_count: usize,
63    resource_count: usize,
64) -> Result<()> {
65    let cost_text = if usage_dependent {
66        format!("${total_daily_cost:.2}+ /day (floor)")
67    } else {
68        format!("${total_daily_cost:.2} /day")
69    };
70    let text = format!(
71        "\u{26a0}\u{fe0f} Audex orphaned-resource budget alert\n\u{2022} Projected cost: `{}`\n\u{2022} Threshold: `${:.2}/day`\n\u{2022} Orphan sessions: {}\n\u{2022} Resources still alive: {}\n\u{2022} Run `tryaudex cleanup <session-id>` to drain.",
72        cost_text, threshold_usd_per_day, orphan_session_count, resource_count,
73    );
74    send(target, &text).await
75}
76
77/// Send a session end notification.
78pub async fn notify_end(
79    target: &NotifyTarget,
80    session: &Session,
81    exit_code: Option<i32>,
82) -> Result<()> {
83    let emoji = match session.status.to_string().as_str() {
84        "completed" => "\u{2705}",
85        "failed" => "\u{274c}",
86        _ => "\u{26a0}\u{fe0f}",
87    };
88    let text = format!(
89        "{} Audex session ended\n\u{2022} Session: `{}`\n\u{2022} Status: {}\n\u{2022} Command: `{}`{}",
90        emoji,
91        session.short_id(),
92        session.status,
93        session.command.join(" "),
94        exit_code.map_or(String::new(), |c| format!("\n\u{2022} Exit code: {}", c)),
95    );
96    send(target, &text).await
97}
98
99async fn send(target: &NotifyTarget, text: &str) -> Result<()> {
100    let client = reqwest::Client::new();
101
102    let (url, body) = match target {
103        NotifyTarget::Slack(webhook) => {
104            let url = if webhook.starts_with("https://") {
105                webhook.clone()
106            } else {
107                format!("https://{}", webhook)
108            };
109            (url, serde_json::json!({ "text": text }))
110        }
111        NotifyTarget::Discord(webhook) => {
112            let url = if webhook.starts_with("https://") {
113                webhook.clone()
114            } else {
115                format!("https://{}", webhook)
116            };
117            (url, serde_json::json!({ "content": text }))
118        }
119        NotifyTarget::Generic(url) => (
120            url.clone(),
121            serde_json::json!({
122                "text": text,
123                "source": "audex",
124            }),
125        ),
126    };
127
128    let resp = client
129        .post(&url)
130        .header("content-type", "application/json")
131        .json(&body)
132        .send()
133        .await
134        .map_err(|e| AvError::Sts(format!("Webhook notification failed: {}", e)))?;
135
136    if !resp.status().is_success() {
137        let status = resp.status();
138        tracing::warn!(
139            "Webhook notification returned non-success status: {}",
140            status
141        );
142        return Err(AvError::Sts(format!(
143            "Webhook notification failed with status: {}",
144            status
145        )));
146    }
147
148    Ok(())
149}