autopulse_service/settings/webhooks/
discord.rs1use 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 pub url: String,
34 pub avatar_url: Option<String>,
36 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, EventType::Found => 52084, EventType::Failed => 16_711_680, EventType::Processed => 39129, 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: 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}