autopulse_service/settings/webhooks/
discord.rs

1use super::{EventType, WebhookBatch};
2use autopulse_utils::{get_timestamp, sify};
3use serde::{Deserialize, Serialize};
4use tracing::trace;
5
6#[derive(Serialize, Clone)]
7#[doc(hidden)]
8pub struct DiscordEmbedField {
9    pub name: String,
10    pub value: String,
11}
12
13#[derive(Serialize, Clone)]
14#[doc(hidden)]
15pub struct DiscordEmbed {
16    pub color: i32,
17    pub timestamp: String,
18    pub fields: Vec<DiscordEmbedField>,
19    pub title: String,
20}
21
22#[derive(Serialize, Clone)]
23#[doc(hidden)]
24pub struct DiscordEmbedContent {
25    pub username: String,
26    pub avatar_url: String,
27    pub embeds: Vec<DiscordEmbed>,
28}
29
30#[derive(Deserialize, Clone)]
31pub struct DiscordWebhook {
32    /// Webhook URL
33    pub url: String,
34    /// Optional avatar URL (default [assets/logo.webp](https://raw.githubusercontent.com/dan-online/autopulse/main/assets/logo.webp))
35    pub avatar_url: Option<String>,
36    /// Optional username (default: autopulse)
37    pub username: Option<String>,
38}
39
40impl DiscordWebhook {
41    fn get_client(&self) -> reqwest::Client {
42        reqwest::Client::builder()
43            .timeout(std::time::Duration::from_secs(10))
44            .build()
45            .expect("failed to build reqwest client")
46    }
47
48    fn truncate_message(message: String, length: usize) -> String {
49        if message.len() > length {
50            format!("{}...", &message[..(length - 3)])
51        } else {
52            message
53        }
54    }
55
56    fn generate_json(&self, batch: &WebhookBatch) -> DiscordEmbedContent {
57        let mut content = DiscordEmbedContent {
58            username: self
59                .username
60                .clone()
61                .unwrap_or_else(|| "autopulse".to_string()),
62            avatar_url: self.avatar_url.clone().unwrap_or_else(|| {
63                "https://raw.githubusercontent.com/dan-online/autopulse/main/assets/logo.webp"
64                    .to_string()
65            }),
66            embeds: vec![],
67        };
68
69        for (event, trigger, files) in batch {
70            let color = match event {
71                EventType::New => 6_061_450,     // grey
72                EventType::Found => 52084,       // green
73                EventType::Failed => 16_711_680, // red
74                EventType::Processed => 39129,   // blue
75                EventType::Retrying | EventType::HashMismatch => 16_776_960,
76            };
77
78            let title = trigger.clone().map_or_else(
79                || {
80                    format!(
81                        "[{}] - {} file{} {}",
82                        event,
83                        files.len(),
84                        sify(files),
85                        event.action()
86                    )
87                },
88                |trigger| {
89                    format!(
90                        "[{}] - [{}] - {} file{} {}",
91                        event,
92                        trigger,
93                        files.len(),
94                        sify(files),
95                        event.action()
96                    )
97                },
98            );
99
100            let fields = vec![
101                DiscordEmbedField {
102                    name: "Timestamp".to_string(),
103                    value: get_timestamp(),
104                },
105                DiscordEmbedField {
106                    name: "Files".to_string(),
107                    // value: files.join("\n"),
108                    value: Self::truncate_message(files.join("\n"), 1024),
109                },
110            ];
111
112            let embed = DiscordEmbed {
113                color,
114                timestamp: chrono::Utc::now().to_rfc3339(),
115                fields,
116                title,
117            };
118
119            content.embeds.push(embed);
120        }
121
122        content
123    }
124
125    #[async_recursion::async_recursion]
126    pub async fn send(&self, batch: &WebhookBatch, retries: u8) -> anyhow::Result<()> {
127        let mut message_queue = vec![];
128
129        for chunk in batch.chunks(10) {
130            let content = self.generate_json(&chunk.to_vec());
131            message_queue.push(content);
132        }
133
134        for message in message_queue {
135            let res = self
136                .get_client()
137                .post(&self.url)
138                .json(&message)
139                .send()
140                .await
141                .map_err(|e| anyhow::anyhow!(e))?;
142
143            if !res.status().is_success() {
144                let reset = res.headers().get("X-RateLimit-Reset");
145
146                if let Some(reset) = reset {
147                    if retries == 0 {
148                        let body = res.text().await?;
149
150                        return Err(anyhow::anyhow!(
151                            "failed to send webhook, retries exhausted: {body}"
152                        ));
153                    }
154
155                    let reset = reset.to_str().unwrap_or_default();
156                    let reset = reset.parse::<u64>().unwrap_or_default();
157                    let now = chrono::Utc::now().timestamp() as u64;
158
159                    if reset > now {
160                        let wait = reset.saturating_sub(now);
161
162                        trace!("rate limited, waiting for {} seconds", wait);
163
164                        tokio::time::sleep(tokio::time::Duration::from_secs(wait)).await;
165
166                        self.send(batch, retries - 1).await?;
167                        continue;
168                    }
169                }
170
171                let body = res.text().await.unwrap_or_else(|_| "no body".to_string());
172
173                return Err(anyhow::anyhow!("failed to send webhook: {}", body));
174            }
175        }
176
177        Ok(())
178    }
179}