autopulse_service/settings/webhooks/
discord.rs1use super::{transport, EventType, WebhookBatch};
2use autopulse_utils::sify;
3use serde::{Deserialize, Serialize};
4
5#[derive(Serialize, Clone)]
6#[doc(hidden)]
7pub struct DiscordEmbedField {
8 pub name: String,
9 pub value: String,
10}
11
12#[derive(Serialize, Clone)]
13#[doc(hidden)]
14pub struct DiscordEmbed {
15 pub color: i32,
16 pub timestamp: String,
17 pub fields: Vec<DiscordEmbedField>,
18 pub title: String,
19}
20
21#[derive(Serialize, Clone)]
22#[doc(hidden)]
23pub struct DiscordEmbedContent {
24 pub username: String,
25 pub avatar_url: String,
26 pub embeds: Vec<DiscordEmbed>,
27}
28
29#[derive(Serialize, Deserialize, Clone)]
30pub struct DiscordWebhook {
31 pub url: String,
33 pub avatar_url: Option<String>,
35 pub username: Option<String>,
37}
38
39impl DiscordWebhook {
40 fn truncate_message(message: String, length: usize) -> String {
41 if length < 3 || message.len() <= length {
42 return message;
43 }
44
45 let cut = message.floor_char_boundary(length - 3);
46 format!("{}...", &message[..cut])
47 }
48
49 fn generate_json(
50 &self,
51 batch: &[(EventType, Option<String>, Vec<String>)],
52 ) -> DiscordEmbedContent {
53 let mut content = DiscordEmbedContent {
54 username: self
55 .username
56 .clone()
57 .unwrap_or_else(|| "autopulse".to_string()),
58 avatar_url: self.avatar_url.clone().unwrap_or_else(|| {
59 "https://raw.githubusercontent.com/dan-online/autopulse/main/assets/logo.webp"
60 .to_string()
61 }),
62 embeds: vec![],
63 };
64
65 for (event, trigger, files) in batch {
66 let timestamp = chrono::Utc::now().to_rfc3339();
67
68 let color = match event {
69 EventType::New => 6_061_450, EventType::Found => 52084, EventType::Failed => 16_711_680, EventType::Processed => 39129, EventType::Retrying | EventType::HashMismatch => 16_776_960,
74 };
75
76 let title = trigger.clone().map_or_else(
77 || {
78 format!(
79 "[{}] - {} file{} {}",
80 event,
81 files.len(),
82 sify(files),
83 event.action()
84 )
85 },
86 |trigger| {
87 format!(
88 "[{}] - [{}] - {} file{} {}",
89 event,
90 trigger,
91 files.len(),
92 sify(files),
93 event.action()
94 )
95 },
96 );
97
98 let fields = vec![
99 DiscordEmbedField {
100 name: "Timestamp".to_string(),
101 value: timestamp.clone(),
102 },
103 DiscordEmbedField {
104 name: "Files".to_string(),
105 value: Self::truncate_message(files.join("\n"), 1024),
107 },
108 ];
109
110 let embed = DiscordEmbed {
111 color,
112 timestamp,
113 fields,
114 title,
115 };
116
117 content.embeds.push(embed);
118 }
119
120 content
121 }
122
123 pub async fn send(
124 &self,
125 batch: &WebhookBatch,
126 retries: u8,
127 timeout_secs: u64,
128 ) -> anyhow::Result<()> {
129 let mut message_queue = vec![];
130
131 for chunk in batch.chunks(10) {
132 let content = self.generate_json(chunk);
133 message_queue.push(content);
134 }
135
136 transport::shared_sender(std::time::Duration::from_secs(timeout_secs))?
137 .send_json(&self.url, &message_queue, retries)
138 .await
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 #[test]
147 fn truncate_ascii_under_limit() {
148 let input = "short".to_string();
149 assert_eq!(DiscordWebhook::truncate_message(input, 10), "short");
150 }
151
152 #[test]
153 fn truncate_ascii_over_limit() {
154 let input = "this is a long message".to_string();
155 let result = DiscordWebhook::truncate_message(input, 10);
156 assert_eq!(result, "this is...");
157 assert_eq!(result.len(), 10);
158 }
159
160 #[test]
161 fn truncate_multibyte_at_boundary() {
162 let input = "abcde😀fgh".to_string();
164 let result = DiscordWebhook::truncate_message(input, 8);
167 assert!(result.ends_with("..."));
168 assert!(result.is_char_boundary(result.len()));
169 }
170
171 #[test]
172 fn truncate_empty_string() {
173 let result = DiscordWebhook::truncate_message(String::new(), 10);
174 assert_eq!(result, "");
175 }
176
177 #[test]
178 fn truncate_exactly_at_limit() {
179 let input = "exactly 10".to_string();
180 assert_eq!(input.len(), 10);
181 assert_eq!(DiscordWebhook::truncate_message(input, 10), "exactly 10");
182 }
183}