1use crate::config::{ChannelConfig, Config, CustomChannel, WebhookChannel};
2use crate::event::CompletionEvent;
3use regex::Regex;
4use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
5use std::process::{Command, Stdio};
6
7#[derive(Debug, Clone)]
9pub struct DeliveryResult {
10 pub channel_id: String,
12
13 pub success: bool,
15
16 pub error: Option<String>,
18}
19
20pub fn notify_selected(
22 config: &Config,
23 selected_channel_ids: &[String],
24 event: &CompletionEvent,
25) -> Vec<DeliveryResult> {
26 selected_channel_ids
27 .iter()
28 .map(|channel_id| {
29 let Some(channel) = config.channels.get(channel_id) else {
30 return DeliveryResult {
31 channel_id: channel_id.clone(),
32 success: false,
33 error: Some("channel not found in config".to_string()),
34 };
35 };
36
37 match send_one(channel, event) {
38 Ok(()) => DeliveryResult {
39 channel_id: channel_id.clone(),
40 success: true,
41 error: None,
42 },
43 Err(error) => DeliveryResult {
44 channel_id: channel_id.clone(),
45 success: false,
46 error: Some(redact_sensitive(&error)),
47 },
48 }
49 })
50 .collect()
51}
52
53fn send_one(channel: &ChannelConfig, event: &CompletionEvent) -> Result<(), String> {
54 match channel {
55 ChannelConfig::Desktop(_) => send_desktop(event),
56 ChannelConfig::Webhook(webhook) => send_webhook(webhook, event),
57 ChannelConfig::Custom(custom) => send_custom(custom, event),
58 }
59}
60
61fn send_desktop(event: &CompletionEvent) -> Result<(), String> {
62 let title = if event.exit_code == 0 {
63 "brb: success".to_string()
64 } else {
65 format!("brb: failed (exit {})", event.exit_code)
66 };
67
68 let duration_s = event.duration_ms as f64 / 1000.0;
69 let body = format!("{} ({:.2}s)", event.command.join(" "), duration_s);
70
71 #[cfg(target_os = "macos")]
72 {
73 let script = format!(
74 "display notification \"{}\" with title \"{}\"",
75 escape_applescript(&body),
76 escape_applescript(&title)
77 );
78
79 let status = Command::new("osascript")
80 .arg("-e")
81 .arg(script)
82 .status()
83 .map_err(|error| format!("failed to run osascript: {error}"))?;
84
85 if status.success() {
86 Ok(())
87 } else {
88 Err("desktop notifier command returned non-zero status".to_string())
89 }
90 }
91
92 #[cfg(target_os = "linux")]
93 {
94 let status = Command::new("notify-send")
95 .arg(title)
96 .arg(body)
97 .status()
98 .map_err(|error| format!("failed to run notify-send: {error}"))?;
99
100 if status.success() {
101 Ok(())
102 } else {
103 Err("desktop notifier command returned non-zero status".to_string())
104 }
105 }
106
107 #[cfg(target_os = "windows")]
108 {
109 let _ = title;
110 let _ = body;
111 Err("desktop channel is not implemented on Windows yet".to_string())
112 }
113
114 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
115 {
116 let _ = title;
117 let _ = body;
118 Err("desktop channel is not supported on this platform".to_string())
119 }
120}
121
122fn send_webhook(webhook: &WebhookChannel, event: &CompletionEvent) -> Result<(), String> {
123 let method = reqwest::Method::from_bytes(webhook.method.as_bytes())
124 .map_err(|_| "invalid HTTP method in webhook config".to_string())?;
125 let headers = build_headers(&webhook.headers)?;
126
127 let client = reqwest::blocking::Client::new();
128 let response = client
129 .request(method, &webhook.url)
130 .headers(headers)
131 .json(event)
132 .send()
133 .map_err(|_| "webhook request failed".to_string())?;
134
135 if response.status().is_success() {
136 Ok(())
137 } else {
138 Err(format!(
139 "webhook returned HTTP {}",
140 response.status().as_u16()
141 ))
142 }
143}
144
145fn build_headers(
146 raw_headers: &std::collections::BTreeMap<String, String>,
147) -> Result<HeaderMap, String> {
148 let mut headers = HeaderMap::new();
149
150 for (key, value) in raw_headers {
151 let name = HeaderName::try_from(key.as_str())
152 .map_err(|_| format!("invalid webhook header name `{key}`"))?;
153 let value = HeaderValue::try_from(value.as_str())
154 .map_err(|_| format!("invalid value for webhook header `{key}`"))?;
155 headers.insert(name, value);
156 }
157
158 Ok(headers)
159}
160
161fn send_custom(custom: &CustomChannel, event: &CompletionEvent) -> Result<(), String> {
162 let mut command = Command::new(&custom.exec);
163 command
164 .args(&custom.args)
165 .envs(&custom.env)
166 .stdin(Stdio::piped())
167 .stdout(Stdio::null())
168 .stderr(Stdio::piped());
169
170 let mut child = command
171 .spawn()
172 .map_err(|_| format!("failed to start custom notifier `{}`", custom.exec))?;
173
174 let payload =
175 serde_json::to_vec(event).map_err(|_| "failed to encode event payload".to_string())?;
176 if let Some(stdin) = child.stdin.as_mut() {
177 use std::io::Write;
178 stdin
179 .write_all(&payload)
180 .map_err(|_| "failed writing event payload to custom notifier".to_string())?;
181 }
182
183 let output = child
184 .wait_with_output()
185 .map_err(|_| "failed waiting for custom notifier process".to_string())?;
186
187 if output.status.success() {
188 return Ok(());
189 }
190
191 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
192 if stderr.is_empty() {
193 Err("custom notifier exited with non-zero status".to_string())
194 } else {
195 Err(format!(
196 "custom notifier failed: {}",
197 truncate_for_error(&stderr, 200)
198 ))
199 }
200}
201
202fn truncate_for_error(input: &str, max_chars: usize) -> String {
203 if input.chars().count() <= max_chars {
204 return input.to_string();
205 }
206
207 let truncated = input.chars().take(max_chars).collect::<String>();
208 format!("{truncated}...")
209}
210
211fn redact_sensitive(input: &str) -> String {
215 let mut output = input.to_string();
216
217 let patterns = [
218 (
219 Regex::new(r"(?i)(authorization\s*[:=]\s*bearer\s+)[^\s]+")
220 .expect("valid authorization redaction regex"),
221 "$1[REDACTED]",
222 ),
223 (
224 Regex::new(r"(?i)((?:token|secret|password|api[_-]?key)\s*[:=]\s*)[^\s,;]+")
225 .expect("valid token redaction regex"),
226 "$1[REDACTED]",
227 ),
228 (
229 Regex::new(r"(?i)(https?://[^/\s:@]+:)[^@\s/]+@")
230 .expect("valid url credentials redaction regex"),
231 "$1[REDACTED]@",
232 ),
233 (
234 Regex::new(r"(?i)([?&](?:token|key|secret|sig)=)[^&\s]+")
235 .expect("valid query secret redaction regex"),
236 "$1[REDACTED]",
237 ),
238 ];
239
240 for (pattern, replacement) in patterns {
241 output = pattern.replace_all(&output, replacement).to_string();
242 }
243
244 output
245}
246
247#[cfg(target_os = "macos")]
248fn escape_applescript(input: &str) -> String {
249 input.replace('\\', "\\\\").replace('"', "\\\"")
250}