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 notifier = win32_notif::ToastsNotifier::new("Microsoft.Windows.Explorer").unwrap();
110
111 let notif = win32_notif::NotificationBuilder::new()
112 .visual(win32_notif::Text::create(0, title))
113 .visual(win32_notif::Text::create(1, body).with_style(win32_notif::HintStyle::Body))
114 .build(0, ¬ifier, "01", "brb")
115 .unwrap();
116
117 notif
118 .show()
119 .map_err(|error| format!("failed to run notify-send: {error}"))
120 }
121
122 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
123 {
124 let _ = title;
125 let _ = body;
126 Err("desktop channel is not supported on this platform".to_string())
127 }
128}
129
130fn send_webhook(webhook: &WebhookChannel, event: &CompletionEvent) -> Result<(), String> {
131 let method = reqwest::Method::from_bytes(webhook.method.as_bytes())
132 .map_err(|_| "invalid HTTP method in webhook config".to_string())?;
133 let headers = build_headers(&webhook.headers)?;
134
135 let client = reqwest::blocking::Client::new();
136 let response = client
137 .request(method, &webhook.url)
138 .headers(headers)
139 .json(event)
140 .send()
141 .map_err(|_| "webhook request failed".to_string())?;
142
143 if response.status().is_success() {
144 Ok(())
145 } else {
146 Err(format!(
147 "webhook returned HTTP {}",
148 response.status().as_u16()
149 ))
150 }
151}
152
153fn build_headers(
154 raw_headers: &std::collections::BTreeMap<String, String>,
155) -> Result<HeaderMap, String> {
156 let mut headers = HeaderMap::new();
157
158 for (key, value) in raw_headers {
159 let name = HeaderName::try_from(key.as_str())
160 .map_err(|_| format!("invalid webhook header name `{key}`"))?;
161 let value = HeaderValue::try_from(value.as_str())
162 .map_err(|_| format!("invalid value for webhook header `{key}`"))?;
163 headers.insert(name, value);
164 }
165
166 Ok(headers)
167}
168
169fn send_custom(custom: &CustomChannel, event: &CompletionEvent) -> Result<(), String> {
170 let mut command = Command::new(&custom.exec);
171 command
172 .args(&custom.args)
173 .envs(&custom.env)
174 .stdin(Stdio::piped())
175 .stdout(Stdio::null())
176 .stderr(Stdio::piped());
177
178 let mut child = command
179 .spawn()
180 .map_err(|_| format!("failed to start custom notifier `{}`", custom.exec))?;
181
182 let payload =
183 serde_json::to_vec(event).map_err(|_| "failed to encode event payload".to_string())?;
184 if let Some(stdin) = child.stdin.as_mut() {
185 use std::io::Write;
186 stdin
187 .write_all(&payload)
188 .map_err(|_| "failed writing event payload to custom notifier".to_string())?;
189 }
190
191 let output = child
192 .wait_with_output()
193 .map_err(|_| "failed waiting for custom notifier process".to_string())?;
194
195 if output.status.success() {
196 return Ok(());
197 }
198
199 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
200 if stderr.is_empty() {
201 Err("custom notifier exited with non-zero status".to_string())
202 } else {
203 Err(format!(
204 "custom notifier failed: {}",
205 truncate_for_error(&stderr, 200)
206 ))
207 }
208}
209
210fn truncate_for_error(input: &str, max_chars: usize) -> String {
211 if input.chars().count() <= max_chars {
212 return input.to_string();
213 }
214
215 let truncated = input.chars().take(max_chars).collect::<String>();
216 format!("{truncated}...")
217}
218
219fn redact_sensitive(input: &str) -> String {
223 let mut output = input.to_string();
224
225 let patterns = [
226 (
227 Regex::new(r"(?i)(authorization\s*[:=]\s*bearer\s+)[^\s]+")
228 .expect("valid authorization redaction regex"),
229 "$1[REDACTED]",
230 ),
231 (
232 Regex::new(r"(?i)((?:token|secret|password|api[_-]?key)\s*[:=]\s*)[^\s,;]+")
233 .expect("valid token redaction regex"),
234 "$1[REDACTED]",
235 ),
236 (
237 Regex::new(r"(?i)(https?://[^/\s:@]+:)[^@\s/]+@")
238 .expect("valid url credentials redaction regex"),
239 "$1[REDACTED]@",
240 ),
241 (
242 Regex::new(r"(?i)([?&](?:token|key|secret|sig)=)[^&\s]+")
243 .expect("valid query secret redaction regex"),
244 "$1[REDACTED]",
245 ),
246 ];
247
248 for (pattern, replacement) in patterns {
249 output = pattern.replace_all(&output, replacement).to_string();
250 }
251
252 output
253}
254
255#[cfg(target_os = "macos")]
256fn escape_applescript(input: &str) -> String {
257 input.replace('\\', "\\\\").replace('"', "\\\"")
258}