Skip to main content

brb_cli/
channels.rs

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/// Notification delivery status for a single channel.
8#[derive(Debug, Clone)]
9pub struct DeliveryResult {
10    /// Channel id from config.
11    pub channel_id: String,
12
13    /// Whether delivery succeeded.
14    pub success: bool,
15
16    /// Optional failure reason.
17    pub error: Option<String>,
18}
19
20/// Sends one event to all selected channel IDs.
21pub 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, &notifier, "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
219/// It sounds stupid but odds are people are accidientally going to send down
220/// sensitive data, we can try our best from out side to prevent it from making it
221/// to the channel.
222fn 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}