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://") || 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
29pub 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
48pub 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
73pub 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}