discord_webhook_lib/
lib.rs

1use reqwest::{
2    header,
3    multipart::{Form, Part},
4    Client,
5};
6use std::io;
7use std::path::Path;
8use std::time::Duration;
9use tokio::time::sleep;
10
11pub struct DiscordMessage {
12    client: Client,
13    webhook_url: String,
14    image_path: Option<String>,
15    message: Option<String>,
16    extra_fields: Option<Vec<(String, String)>>,
17}
18
19impl DiscordMessage {
20    pub fn builder(webhook_url: impl Into<String>) -> DiscordMessageBuilder {
21        DiscordMessageBuilder {
22            webhook_url: webhook_url.into(),
23            gif_path: None,
24            message: None,
25            extra_fields: None,
26        }
27    }
28
29    pub async fn send(&self) -> Result<(), Box<dyn std::error::Error>> {
30        // Pre-read file (if any) and compute metadata so we can rebuild the form across retries
31        let (file_bytes, filename, mime_opt): (Option<Vec<u8>>, Option<String>, Option<String>) =
32            if let Some(image_path) = self.image_path.as_ref() {
33                let bytes = tokio::fs::read(image_path)
34                    .await
35                    .map_err(|e| io::Error::new(io::ErrorKind::Other, format!(
36                        "Failed to read image file at '{}': {}",
37                        image_path, e
38                    )))?;
39                let filename = Path::new(image_path)
40                    .file_name()
41                    .map(|os_str| os_str.to_string_lossy().to_string())
42                    .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Invalid file name"))?;
43                let mime = guess_mime_from_filename(&filename).to_string();
44                (Some(bytes), Some(filename), Some(mime))
45            } else {
46                (None, None, None)
47            };
48
49        let max_attempts: usize = 5;
50        let mut last_status: Option<reqwest::StatusCode> = None;
51        let mut last_body_snippet: Option<String> = None;
52
53        for attempt in 0..max_attempts {
54            // Build multipart form for this attempt
55            let mut form = Form::new();
56
57            if let (Some(ref bytes), Some(ref name), Some(ref mime)) = (&file_bytes, &filename, &mime_opt) {
58                let part = Part::bytes(bytes.clone())
59                    .file_name(name.clone())
60                    .mime_str(mime)?;
61                form = form.part("file1", part);
62            }
63
64            if let Some(ref fields) = self.extra_fields {
65                for (key, value) in fields {
66                    form = form.text(key.clone(), value.clone());
67                }
68            }
69
70            if let Some(ref message) = self.message {
71                form = form.text("content", message.clone());
72            }
73
74            let resp_result = self
75                .client
76                .post(&self.webhook_url)
77                .multipart(form)
78                .timeout(Duration::from_secs(30))
79                .send()
80                .await;
81
82            match resp_result {
83                Ok(res) => {
84                    if res.status().is_success() {
85                        return Ok(());
86                    }
87
88                    let status = res.status();
89                    last_status = Some(status);
90
91                    // Handle rate limiting (429)
92                    if status.as_u16() == 429 {
93                        let delay = if let Some(secs) = parse_retry_after_secs(res.headers()) {
94                            // Convert seconds (may be fractional) to duration
95                            let ms = (secs * 1000.0).ceil() as u64;
96                            Duration::from_millis(ms)
97                        } else {
98                            compute_backoff(attempt as u32)
99                        };
100
101                        if attempt + 1 < max_attempts {
102                            sleep(delay).await;
103                            continue;
104                        } else {
105                            let body_text = res.text().await.ok();
106                            last_body_snippet = body_text.map(|s| truncate(&s, 500));
107                            break;
108                        }
109                    }
110
111                    // Retry on server errors
112                    if status.is_server_error() {
113                        if attempt + 1 < max_attempts {
114                            sleep(compute_backoff(attempt as u32)).await;
115                            continue;
116                        } else {
117                            let body_text = res.text().await.ok();
118                            last_body_snippet = body_text.map(|s| truncate(&s, 500));
119                            break;
120                        }
121                    }
122
123                    // For other 4xx, treat as fatal
124                    let body_text = res.text().await.ok();
125                    let snippet = body_text.as_deref().unwrap_or("");
126                    return Err(Box::new(io::Error::new(
127                        io::ErrorKind::Other,
128                        format!(
129                            "Discord webhook returned {}: {}",
130                            status,
131                            truncate(snippet, 500)
132                        ),
133                    )));
134                }
135                Err(err) => {
136                    if attempt + 1 < max_attempts {
137                        sleep(compute_backoff(attempt as u32)).await;
138                        continue;
139                    } else {
140                        return Err(Box::new(io::Error::new(
141                            io::ErrorKind::Other,
142                            format!(
143                                "Failed to send request after {} attempts: {}",
144                                max_attempts, err
145                            ),
146                        )));
147                    }
148                }
149            }
150        }
151
152        let status_str = last_status
153            .map(|s| s.to_string())
154            .unwrap_or_else(|| "unknown".to_string());
155        let body_snip = last_body_snippet.unwrap_or_default();
156        Err(Box::new(io::Error::new(
157            io::ErrorKind::Other,
158            format!(
159                "unable to send after retries (last status {}): {}",
160                status_str, body_snip
161            ),
162        )))
163    }
164}
165
166pub struct DiscordMessageBuilder {
167    webhook_url: String,
168    gif_path: Option<String>,
169    message: Option<String>,
170    extra_fields: Option<Vec<(String, String)>>,
171}
172
173impl DiscordMessageBuilder {
174    pub fn gif_path(&mut self, path: impl Into<String>) {
175        self.gif_path = Some(path.into());
176    }
177
178    pub fn add_message(&mut self, message: impl Into<String>) {
179        self.message = Option::from(message.into());
180    }
181
182    pub fn add_field(&mut self, key: impl Into<String>, value: impl Into<String>) {
183        match self.extra_fields {
184            Some(ref mut fields) => fields.push((key.into(), value.into())),
185            None => self.extra_fields = Some(vec![(key.into(), value.into())]),
186        }
187    }
188
189    pub fn build(self) -> DiscordMessage {
190        DiscordMessage {
191            client: Client::new(),
192            webhook_url: self.webhook_url,
193            image_path: self.gif_path,
194            message: self.message,
195            extra_fields: self.extra_fields,
196        }
197    }
198}
199
200fn guess_mime_from_filename(filename: &str) -> &'static str {
201    let ext = Path::new(filename)
202        .extension()
203        .and_then(|s| s.to_str())
204        .map(|s| s.to_ascii_lowercase());
205    match ext.as_deref() {
206        Some("jpg") | Some("jpeg") => "image/jpeg",
207        Some("png") => "image/png",
208        Some("gif") => "image/gif",
209        Some("webp") => "image/webp",
210        Some("bmp") => "image/bmp",
211        Some("tiff") | Some("tif") => "image/tiff",
212        Some("svg") => "image/svg+xml",
213        _ => "application/octet-stream",
214    }
215}
216
217fn parse_retry_after_secs(headers: &header::HeaderMap) -> Option<f64> {
218    if let Some(v) = headers.get(header::RETRY_AFTER) {
219        if let Ok(s) = v.to_str() {
220            if let Ok(n) = s.parse::<f64>() {
221                return Some(n.max(0.0));
222            }
223        }
224    }
225    if let Some(v) = headers.get("x-ratelimit-reset-after") {
226        if let Ok(s) = v.to_str() {
227            if let Ok(n) = s.parse::<f64>() {
228                return Some(n.max(0.0));
229            }
230        }
231    }
232    None
233}
234
235fn compute_backoff(attempt: u32) -> Duration {
236    const BASE_MS: u64 = 500;
237    const MAX_MS: u64 = 5_000;
238    let shift = attempt.min(63);
239    let factor = 1u64 << shift;
240    let ms = BASE_MS.saturating_mul(factor).min(MAX_MS);
241    Duration::from_millis(ms)
242}
243
244fn truncate(s: &str, max: usize) -> String {
245    let mut out: String = s.chars().take(max).collect();
246    if s.chars().count() > max {
247        out.push_str("...");
248    }
249    out
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use std::env;
256
257    #[test]
258    fn send_test() {
259        let discord_webhook = env::var_os("DISCORD_WEBHOOK_URL");
260        println!("{:?}", discord_webhook);
261        if let Some(dwebhook) = discord_webhook {
262            let mut builder = DiscordMessage::builder(dwebhook.to_str().unwrap());
263            builder.gif_path("./t.jpg");
264            // builder.add_message("amessage");
265            builder.add_field("username", "test");
266            builder.add_field("content", "filename");
267            let dhm = builder.build();
268
269            let rt = tokio::runtime::Runtime::new().expect("Could not create tokio runtime");
270            rt.block_on(dhm.send()).unwrap();
271        }
272    }
273
274    #[test]
275    fn send_message_only() {
276        let discord_webhook = env::var_os("DISCORD_WEBHOOK_URL");
277        println!("{:?}", discord_webhook);
278        if let Some(dwebhook) = discord_webhook {
279            let mut builder = DiscordMessage::builder(dwebhook.to_str().unwrap());
280            builder.add_field("username", "test");
281            builder.add_field("content", "a message");
282            let dhm = builder.build();
283
284            let rt = tokio::runtime::Runtime::new().expect("Could not create tokio runtime");
285            rt.block_on(dhm.send()).unwrap();
286        }
287    }
288
289    #[test]
290    fn test_guess_mime_from_filename() {
291        assert_eq!(guess_mime_from_filename("image.jpg"), "image/jpeg");
292        assert_eq!(guess_mime_from_filename("image.JPEG"), "image/jpeg");
293        assert_eq!(guess_mime_from_filename("image.png"), "image/png");
294        assert_eq!(guess_mime_from_filename("image.gif"), "image/gif");
295        assert_eq!(guess_mime_from_filename("image.webp"), "image/webp");
296        assert_eq!(guess_mime_from_filename("image.unknown"), "application/octet-stream");
297    }
298
299    #[test]
300    fn test_compute_backoff() {
301        assert_eq!(compute_backoff(0), Duration::from_millis(500));
302        assert_eq!(compute_backoff(1), Duration::from_millis(1_000));
303        assert_eq!(compute_backoff(2), Duration::from_millis(2_000));
304        assert_eq!(compute_backoff(3), Duration::from_millis(4_000));
305        assert_eq!(compute_backoff(4), Duration::from_millis(5_000)); // capped
306        assert_eq!(compute_backoff(10), Duration::from_millis(5_000)); // still capped
307    }
308}