blurple_hook/
lib.rs

1use chrono::prelude::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use anyhow::format_err;
4use sha1::{Sha1, Digest};
5
6#[cfg(feature = "reqwest")]
7use reqwest as reqwest;
8
9#[cfg(all(feature = "rquest", not(feature = "reqwest")))]
10use rquest as reqwest;
11
12#[cfg(feature = "queue")]
13pub mod queue {
14    use std::collections::VecDeque;
15    use std::sync::Arc;
16    use std::time::Duration;
17    use tokio::sync::Mutex;
18    use tokio::task::JoinHandle;
19    use tokio::time::Instant;
20    use crate::Webhook;
21
22    pub struct WebhookQueue {
23        pub webhooks: Arc<Mutex<VecDeque<Webhook>>>,
24    }
25
26    impl WebhookQueue {
27        pub fn new() -> Self {
28            Self {
29                webhooks: Arc::new(Mutex::new(VecDeque::new())),
30            }
31        }
32
33        pub async fn enqueue(queue: Arc<Mutex<VecDeque<Webhook>>>, webhook: Webhook) {
34            let mut q = queue.lock().await;
35            q.push_front(webhook);
36        }
37
38        pub async fn enqueue_multi(queue: Arc<Mutex<VecDeque<Webhook>>>, webhooks: Vec<Webhook>) {
39            let mut q = queue.lock().await;
40            for webhook in webhooks {
41                q.push_front(webhook);
42            }
43        }
44
45        pub fn start(self) -> JoinHandle<Self> {
46            tokio::task::spawn(async move {
47                loop {
48                    let (one, two) = {
49                        let mut whs = self.webhooks.as_ref().lock().await;
50                        let one = whs.pop_back();
51                        let two = whs.pop_back();
52                        (one, two)
53                    };
54
55                    if cfg!(test) {
56                        if one.is_none() && two.is_none() {
57                            return self;
58                        }
59                    }
60
61                    // only 2 so sequential is fine
62                    let _ = match one {
63                        Some(w) => w.send().await,
64                        None => Ok(()),
65                    };
66
67                    let _ = match two {
68                        Some(w) => w.send().await,
69                        None => Ok(()),
70                    };
71
72                    // we can send 2 webhooks every 2 seconds,
73                    tokio::time::sleep_until(Instant::now() + Duration::from_millis(2000)).await;
74                }
75            })
76        }
77    }
78}
79
80
81#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
82pub struct Webhook {
83    #[serde(skip)]
84    webhook_url: String,
85    content: Option<String>,
86    username: Option<String>,
87    avatar_url: Option<String>,
88    embeds: Vec<Embed>,
89    components: Vec<Component>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
93struct Component {}
94
95#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
96pub struct Embed {
97    title: Option<String>,
98    #[serde(rename = "type")]
99    _type: String,
100    description: Option<String>,
101    url: Option<String>,
102    timestamp: Option<String>,
103    color: Option<usize>,
104    footer: Option<Footer>,
105    image: Option<Image>,
106    thumbnail: Option<Thumbnail>,
107    video: Option<Video>,
108    provider: Option<Provider>,
109    author: Option<Author>,
110    fields: Vec<Field>,
111}
112#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
113struct Footer {
114    text: String,
115    icon_url: Option<String>,
116    proxy_icon_url: Option<String>,
117}
118#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
119struct Image {
120    url: String,
121    proxy_url: Option<String>,
122    height: Option<usize>,
123    width: Option<usize>,
124}
125#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
126struct Thumbnail {
127    url: String,
128    proxy_url: Option<String>,
129    height: Option<usize>,
130    width: Option<usize>,
131}
132#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
133struct Video {
134    url: String,
135    proxy_url: Option<String>,
136    height: Option<usize>,
137    width: Option<usize>,
138}
139#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
140struct Provider {
141    name: Option<String>,
142    url: Option<String>,
143}
144#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
145struct Author {
146    name: String,
147    url: Option<String>,
148    icon_url: Option<String>,
149    proxy_icon_url: Option<String>,
150}
151#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
152pub struct Field {
153    pub name: String,
154    pub value: String,
155    pub inline: bool,
156}
157pub enum ColourType<S: AsRef<str>> {
158    Hex(S),
159    Integer(usize),
160    FromSeed(S)
161}
162
163impl Webhook {
164    pub fn new<S: AsRef<str>>(webhook_url: S) -> Webhook {
165        Webhook {
166            webhook_url: webhook_url.as_ref().to_string(),
167            content: None,
168            username: None,
169            avatar_url: None,
170            embeds: Vec::new(),
171            components: Vec::new(),
172        }
173    }
174    pub fn set_content<S: AsRef<str>>(mut self, content: S) -> Self {
175        self.content = Some(content.as_ref().to_string());
176        self
177    }
178    pub fn set_username<S: AsRef<str>>(mut self, username: S) -> Self {
179        self.username = Some(username.as_ref().to_string());
180        self
181    }
182    pub fn set_avatar_url<S: AsRef<str>>(mut self, url: S) -> Self {
183        self.avatar_url = Some(url.as_ref().to_string());
184        self
185    }
186    pub fn add_embed(mut self, embed: Embed) -> Self {
187        self.embeds.push(embed);
188        self
189    }
190
191    pub fn add_embeds(mut self, embeds: &mut Vec<Embed>) -> Self {
192        self.embeds.append(embeds);
193        self
194    }
195
196    #[cfg(not(feature = "retry"))]
197    pub async fn send(&self) -> anyhow::Result<()> {
198        let client = reqwest::Client::new();
199
200        let body = serde_json::to_string(self).unwrap();
201
202        let resp = client
203            .post(format!("{}?wait=true", &self.webhook_url))
204            .header("Content-Type", "application/json")
205            .body(body)
206            .send()
207            .await?;
208
209        match resp.status() {
210            reqwest::StatusCode::NO_CONTENT | reqwest::StatusCode::OK => {
211                Ok(())
212            },
213            _ => {
214                let body = resp.text().await.unwrap_or(String::from(""));
215                Err(format_err!("Failed to send request, {}", body))
216            }
217        }
218    }
219
220
221    #[cfg(feature = "retry")]
222    #[async_recursion::async_recursion]
223    pub async fn send(&self) -> anyhow::Result<()> {
224        let client = reqwest::Client::new();
225
226        let body = serde_json::to_string(self).unwrap();
227
228        let resp = client
229            .post(format!("{}?wait=true", &self.webhook_url))
230            .header("Content-Type", "application/json")
231            .body(body)
232            .send()
233            .await?;
234
235        match resp.status() {
236            reqwest::StatusCode::NO_CONTENT | reqwest::StatusCode::OK => {
237                Ok(())
238            },
239            reqwest::StatusCode::TOO_MANY_REQUESTS => {
240                use std::time::Duration;
241                use tokio::time::{Instant, sleep_until};
242                let retry_after = match resp.headers().get("Retry-After") {
243                    Some(header) => {
244                        let str = header.to_str().unwrap_or("5");
245                        str.parse::<u64>().unwrap_or(5)
246                    },
247                    None => return Err(format_err!("Missing \"Retry After\" header"))
248                };
249                log::warn!("Webhook rate limited, retrying in {} seconds", retry_after);
250                sleep_until(Instant::now() + Duration::from_secs(retry_after)).await;
251                self.send().await
252            },
253            _ => {
254                let body = resp.text().await.unwrap_or(String::from(""));
255                Err(format_err!("Failed to send request, {}", body))
256            }
257        }
258    }
259}
260
261impl Embed {
262    pub fn new() -> Embed {
263        Embed {
264            title: None,
265            _type: "rich".to_string(),
266            description: None,
267            url: None,
268            timestamp: None,
269            color: None,
270            footer: None,
271            image: None,
272            thumbnail: None,
273            video: None,
274            provider: None,
275            author: None,
276            fields: Vec::new(),
277        }
278    }
279    pub fn set_title<S: AsRef<str>>(mut self, title: S) -> Self {
280        self.title = Some(title.as_ref().to_string());
281        self
282    }
283    pub fn set_description<S: AsRef<str>>(mut self, description: S) -> Self {
284        self.description = Some(description.as_ref().to_string());
285        self
286    }
287    pub fn set_url<S: AsRef<str>>(mut self, url: S) -> Self {
288        self.url = Some(url.as_ref().to_string());
289        self
290    }
291    pub fn set_timestamp(mut self, timestamp: Option<&std::time::SystemTime>) -> Self {
292        let timestamp: DateTime<Utc> = match timestamp {
293            Some(ts) => (*ts).into(),
294            None => Utc::now(),
295        };
296        let timestamp = timestamp.format("%+").to_string();
297        self.timestamp = Some(timestamp);
298        self
299    }
300    pub fn set_colour<S: AsRef<str>>(mut self, colour: ColourType<S>) -> Self {
301        let colour: usize = match colour {
302            ColourType::Hex(hex) => usize::from_str_radix(
303                hex.as_ref()
304                    .trim_start_matches('#')
305                    .trim_start_matches("0x"),
306                16,
307            )
308                .unwrap_or(10066329),
309            ColourType::Integer(int) => int,
310            ColourType::FromSeed(seed) => {
311                let mut hasher = Sha1::new();
312                hasher.update(seed.as_ref().as_bytes());
313                let result = hasher.finalize();
314                let encoded = format!("#{}", &hex::encode(result)[0..6]);
315                return self.set_colour(ColourType::Hex(encoded))
316            }
317        };
318
319        self.color = Some(colour);
320        self
321    }
322    pub fn set_color<S: AsRef<str>>(self, color: ColourType<S>) -> Self {
323        self.set_colour(color)
324    }
325    pub fn set_footer<A: AsRef<str>, B: AsRef<str>, C: AsRef<str>>(
326        mut self,
327        text: A,
328        icon_url: Option<B>,
329        proxy_icon_url: Option<C>,
330    ) -> Self {
331        let icon_url = icon_url.map(|n| n.as_ref().to_string());
332        let proxy_icon_url = proxy_icon_url.map(|n| n.as_ref().to_string());
333
334        self.footer = Some(Footer {
335            text: text.as_ref().to_string(),
336            icon_url,
337            proxy_icon_url,
338        });
339
340        self
341    }
342    pub fn set_image<A: AsRef<str>, B: AsRef<str>>(
343        mut self,
344        url: A,
345        proxy_url: Option<B>,
346        height: Option<usize>,
347        width: Option<usize>,
348    ) -> Self {
349        let proxy_url = proxy_url.map(|p| p.as_ref().to_string());
350
351        self.image = Some(Image {
352            url: url.as_ref().to_string(),
353            proxy_url,
354            height,
355            width,
356        });
357
358        self
359    }
360    pub fn set_thumbnail<A: AsRef<str>, B: AsRef<str>>(
361        mut self,
362        url: A,
363        proxy_url: Option<B>,
364        height: Option<usize>,
365        width: Option<usize>,
366    ) -> Self {
367        let proxy_url = proxy_url.map(|p| p.as_ref().to_string());
368
369        self.thumbnail = Some(Thumbnail {
370            url: url.as_ref().to_string(),
371            proxy_url,
372            height,
373            width,
374        });
375
376        self
377    }
378    pub fn set_video<A: AsRef<str>, B: AsRef<str>>(
379        mut self,
380        url: A,
381        proxy_url: Option<B>,
382        height: Option<usize>,
383        width: Option<usize>,
384    ) -> Self {
385        let proxy_url = proxy_url.map(|p| p.as_ref().to_string());
386
387        self.video = Some(Video {
388            url: url.as_ref().to_string(),
389            proxy_url,
390            height,
391            width,
392        });
393
394        self
395    }
396    pub fn set_provider<A: AsRef<str>, B: AsRef<str>>(mut self, name: Option<A>, url: Option<B>) -> Self {
397        let name = name.map(|n| n.as_ref().to_string());
398        let url = url.map(|n| n.as_ref().to_string());
399        self.provider = Some(Provider { name, url });
400        self
401    }
402    pub fn set_author<A: AsRef<str>, B: AsRef<str>, C: AsRef<str>, D: AsRef<str>>(
403        mut self,
404        name: A,
405        url: Option<B>,
406        icon_url: Option<C>,
407        proxy_icon_url: Option<D>,
408    ) -> Self {
409        let url = url.map(|n| n.as_ref().to_string());
410        let icon_url = icon_url.map(|n| n.as_ref().to_string());
411        let proxy_icon_url = proxy_icon_url.map(|n| n.as_ref().to_string());
412        self.author = Some(Author {
413            name: name.as_ref().to_string(),
414            url,
415            icon_url,
416            proxy_icon_url,
417        });
418
419        self
420    }
421    pub fn add_field<A: AsRef<str>, B: AsRef<str>>(mut self, name: A, value: B, inline: bool) -> Self {
422        let field = Field {
423            name: name.as_ref().to_string(),
424            value: value.as_ref().to_string(),
425            inline,
426        };
427
428        self.fields.push(field);
429
430        self
431    }
432
433    pub fn add_fields(mut self, fields: &mut Vec<Field>) -> Self {
434        self.fields.append(fields);
435        self
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use std::env;
442    use crate::{Author, ColourType, Embed, Field, Footer, Thumbnail, Webhook};
443
444    #[test]
445    fn create_embed() {
446        let embed = Embed::new()
447            .set_colour(ColourType::Hex("#FFFFFF"))
448            .set_author("Author Name", Some("https://example.com/"), None::<String>, None::<String>)
449            .set_thumbnail("https://example.com/", None::<String>, None, None)
450            .set_title("Example")
451            .set_url("https://example.com/")
452            .set_footer("Footer Text", None::<String>, None::<String>)
453            .set_description("Description Text")
454            .add_field("Example 1", "Value 1", true)
455            .add_fields(&mut vec![
456                Field {
457                    name: "Example 2".to_string(),
458                    value: "Value 2".to_string(),
459                    inline: false,
460                },
461                Field {
462                    name: "Example 3".to_string(),
463                    value: "Value 3".to_string(),
464                    inline: false,
465                }
466            ]);
467
468        let expected = Embed {
469            title: Some("Example".to_string()),
470            _type: "rich".to_string(),
471            description: Some("Description Text".to_string()),
472            url: Some("https://example.com/".to_string()),
473            timestamp: None,
474            color: Some(16777215),
475            footer: Some(Footer {
476                text: "Footer Text".to_string(),
477                icon_url: None,
478                proxy_icon_url: None,
479            }),
480            image: None,
481            thumbnail: Some(Thumbnail {
482                url: "https://example.com/".to_string(),
483                proxy_url: None,
484                height: None,
485                width: None,
486            }),
487            video: None,
488            provider: None,
489            author: Some(Author {
490                name: "Author Name".to_string(),
491                url: Some("https://example.com/".to_string()),
492                icon_url: None,
493                proxy_icon_url: None,
494            }),
495            fields: vec![
496                Field {
497                    name: "Example 1".to_string(),
498                    value: "Value 1".to_string(),
499                    inline: true,
500                },
501                Field {
502                    name: "Example 2".to_string(),
503                    value: "Value 2".to_string(),
504                    inline: false,
505                },
506                Field {
507                    name: "Example 3".to_string(),
508                    value: "Value 3".to_string(),
509                    inline: false,
510                }
511            ],
512        };
513        assert_eq!(embed, expected);
514    }
515
516    #[test]
517    fn embed_adds_to_webhook() {
518        let webhook = Webhook::new("https://discord.com/webhook")
519            .set_content("Content Text")
520            .set_username("Test Username");
521
522        let embed = Embed::new()
523            .set_colour(ColourType::Hex("#FFFFFF"))
524            .set_author("Author Name", Some("https://example.com/"), None::<String>, None::<String>)
525            .set_thumbnail("https://example.com/", None::<String>, None, None)
526            .set_title("Example")
527            .set_url("https://example.com/")
528            .set_footer("Footer Text", None::<String>, None::<String>)
529            .set_description("Description Text")
530            .add_field("Example 1", "Value 1", true)
531            .add_fields(&mut vec![
532                Field {
533                    name: "Example 2".to_string(),
534                    value: "Value 2".to_string(),
535                    inline: false,
536                },
537                Field {
538                    name: "Example 3".to_string(),
539                    value: "Value 3".to_string(),
540                    inline: false,
541                }
542            ]);
543
544        let webhook = webhook.add_embed(embed);
545
546        let expected = Webhook {
547            webhook_url: "https://discord.com/webhook".to_string(),
548            content: Some("Content Text".to_string()),
549            username: Some("Test Username".to_string()),
550            avatar_url: None,
551            embeds: vec![
552                Embed {
553                    title: Some("Example".to_string()),
554                    _type: "rich".to_string(),
555                    description: Some("Description Text".to_string()),
556                    url: Some("https://example.com/".to_string()),
557                    timestamp: None,
558                    color: Some(16777215),
559                    footer: Some(Footer {
560                        text: "Footer Text".to_string(),
561                        icon_url: None,
562                        proxy_icon_url: None,
563                    }),
564                    image: None,
565                    thumbnail: Some(Thumbnail {
566                        url: "https://example.com/".to_string(),
567                        proxy_url: None,
568                        height: None,
569                        width: None,
570                    }),
571                    video: None,
572                    provider: None,
573                    author: Some(Author {
574                        name: "Author Name".to_string(),
575                        url: Some("https://example.com/".to_string()),
576                        icon_url: None,
577                        proxy_icon_url: None,
578                    }),
579                    fields: vec![
580                        Field {
581                            name: "Example 1".to_string(),
582                            value: "Value 1".to_string(),
583                            inline: true,
584                        },
585                        Field {
586                            name: "Example 2".to_string(),
587                            value: "Value 2".to_string(),
588                            inline: false,
589                        },
590                        Field {
591                            name: "Example 3".to_string(),
592                            value: "Value 3".to_string(),
593                            inline: false,
594                        }
595                    ],
596                }
597            ],
598            components: vec![],
599        };
600
601        assert_eq!(webhook, expected);
602    }
603
604    #[tokio::test]
605    async fn submit_webhook() {
606
607        let webhook_url = env::var("WEBHOOK").unwrap();
608        let webhook = Webhook::new(&webhook_url);
609
610        let embed = Embed::new()
611            .set_title("Blurple Test")
612            .set_description("Testing Blurple")
613            .set_url("https://example.com/")
614            .set_timestamp(None)
615            .add_field("Example 1", "Value 1", true)
616            .add_fields(&mut vec![
617                Field {
618                    name: "Example 2".to_string(),
619                    value: "Value 2".to_string(),
620                    inline: true,
621                },
622                Field {
623                    name: "Example 3".to_string(),
624                    value: "Value 3".to_string(),
625                    inline: false,
626                },
627                Field {
628                    name: "Example 4".to_string(),
629                    value: "Example 4".to_string(),
630                    inline: false,
631                }
632            ]);
633
634        let webhook = webhook.add_embed(embed);
635        let result = webhook.send().await;
636
637        assert!(result.is_ok());
638    }
639    #[cfg(feature = "queue")]
640    #[tokio::test]
641    async fn test_queue() {
642        use crate::queue::WebhookQueue;
643        use std::sync::Arc;
644
645        let queue = WebhookQueue::new();
646
647        let webhook_url = env::var("WEBHOOK").unwrap();
648
649        let webhooks = Arc::clone(&queue.webhooks);
650        for i in 0..5 {
651            let webhooks = Arc::clone(&webhooks);
652            let embed = Embed::new().set_title("Example");
653            let webhook = Webhook::new(&webhook_url).add_embed(embed);
654
655            WebhookQueue::enqueue(webhooks, webhook).await;
656        }
657
658        {
659            let webhooks = Arc::clone(&webhooks);
660            let webhooks = webhooks.lock().await;
661            assert_eq!(webhooks.len(), 5, "Len is not 5, {}", webhooks.len());
662        }
663
664        let _ = queue.start().await;
665
666        let webhooks = Arc::clone(&webhooks);
667        let webhooks = webhooks.lock().await;
668        assert!(webhooks.is_empty(), "Webhooks not empty, {}", webhooks.len())
669    }
670}