1use crate::error::{AvError, Result};
2use crate::session::Session;
3
4pub enum NotifyTarget {
6 Slack(String),
7 Discord(String),
8 Generic(String),
9}
10
11impl NotifyTarget {
12 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
33pub 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
52pub 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
77pub 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}