autopulse_service/settings/webhooks/
discord.rs

1use 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    /// Webhook URL
32    pub url: String,
33    /// Optional avatar URL (default [assets/logo.webp](https://raw.githubusercontent.com/dan-online/autopulse/main/assets/logo.webp))
34    pub avatar_url: Option<String>,
35    /// Optional username (default: autopulse)
36    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,     // grey
70                EventType::Found => 52084,       // green
71                EventType::Failed => 16_711_680, // red
72                EventType::Processed => 39129,   // blue
73                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: files.join("\n"),
106                    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        // '😀' is 4 bytes; cutting mid-emoji must not panic
163        let input = "abcde😀fgh".to_string();
164        // length=8 → cut at 5, but byte 5 is inside the 4-byte emoji (bytes 5..9)
165        // floor_char_boundary(5) should back up to byte 5 which is the start of 😀
166        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}