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 _ = 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
211/// It sounds stupid but odds are people are accidientally going to send down
212/// sensitive data, we can try our best from out side to prevent it from making it
213/// to the channel.
214fn 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}