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://") || url.starts_with("http://") {
19            Ok(Self::Generic(url.to_string()))
20        } else {
21            Err(AvError::InvalidPolicy(format!(
22                "Invalid notify URL '{}'. Use slack://WEBHOOK_URL, discord://WEBHOOK_URL, or an HTTPS URL",
23                url
24            )))
25        }
26    }
27}
28
29/// Send a session start notification.
30pub async fn notify_start(target: &NotifyTarget, session: &Session) -> Result<()> {
31    let actions: Vec<String> = session
32        .policy
33        .actions
34        .iter()
35        .map(|a| a.to_iam_action())
36        .collect();
37    let text = format!(
38        "Audex session started\n\u{2022} Session: `{}`\n\u{2022} Role: `{}`\n\u{2022} Actions: `{}`\n\u{2022} Command: `{}`\n\u{2022} TTL: {}s",
39        &session.id[..8],
40        session.role_arn,
41        actions.join(", "),
42        session.command.join(" "),
43        session.ttl_seconds,
44    );
45    send(target, &text).await
46}
47
48/// Send a budget-alert notification when orphaned resources exceed a cost
49/// threshold. `total_daily_cost` is the aggregate from
50/// `cleanup::estimate_daily_cost_total` across every orphan session, and
51/// `usage_dependent` indicates the total is a floor rather than an exact
52/// figure (so we mark the alert as "floor" in that case).
53pub async fn notify_orphan_budget_alert(
54    target: &NotifyTarget,
55    threshold_usd_per_day: f64,
56    total_daily_cost: f64,
57    usage_dependent: bool,
58    orphan_session_count: usize,
59    resource_count: usize,
60) -> Result<()> {
61    let cost_text = if usage_dependent {
62        format!("${total_daily_cost:.2}+ /day (floor)")
63    } else {
64        format!("${total_daily_cost:.2} /day")
65    };
66    let text = format!(
67        "\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.",
68        cost_text, threshold_usd_per_day, orphan_session_count, resource_count,
69    );
70    send(target, &text).await
71}
72
73/// Send a session end notification.
74pub async fn notify_end(
75    target: &NotifyTarget,
76    session: &Session,
77    exit_code: Option<i32>,
78) -> Result<()> {
79    let emoji = match session.status.to_string().as_str() {
80        "completed" => "\u{2705}",
81        "failed" => "\u{274c}",
82        _ => "\u{26a0}\u{fe0f}",
83    };
84    let text = format!(
85        "{} Audex session ended\n\u{2022} Session: `{}`\n\u{2022} Status: {}\n\u{2022} Command: `{}`{}",
86        emoji,
87        &session.id[..8],
88        session.status,
89        session.command.join(" "),
90        exit_code.map_or(String::new(), |c| format!("\n\u{2022} Exit code: {}", c)),
91    );
92    send(target, &text).await
93}
94
95async fn send(target: &NotifyTarget, text: &str) -> Result<()> {
96    let client = reqwest::Client::new();
97
98    let (url, body) = match target {
99        NotifyTarget::Slack(webhook) => {
100            let url = if webhook.starts_with("https://") {
101                webhook.clone()
102            } else {
103                format!("https://{}", webhook)
104            };
105            (url, serde_json::json!({ "text": text }))
106        }
107        NotifyTarget::Discord(webhook) => {
108            let url = if webhook.starts_with("https://") {
109                webhook.clone()
110            } else {
111                format!("https://{}", webhook)
112            };
113            (url, serde_json::json!({ "content": text }))
114        }
115        NotifyTarget::Generic(url) => (
116            url.clone(),
117            serde_json::json!({
118                "text": text,
119                "source": "audex",
120            }),
121        ),
122    };
123
124    client
125        .post(&url)
126        .header("content-type", "application/json")
127        .json(&body)
128        .send()
129        .await
130        .map_err(|e| AvError::Sts(format!("Webhook notification failed: {}", e)))?;
131
132    Ok(())
133}