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 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 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 if status.as_u16() == 429 {
93 let delay = if let Some(secs) = parse_retry_after_secs(res.headers()) {
94 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 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 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_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)); assert_eq!(compute_backoff(10), Duration::from_millis(5_000)); }
308}