Skip to main content

autopulse_service/settings/webhooks/
discord.rs

1use super::{transport, EventType, WebhookBatch};
2use autopulse_utils::sify;
3use serde::{de::Error as _, Deserialize, Serialize};
4
5/// One of the two broadcast mention literals.
6#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
7#[serde(rename_all = "lowercase")]
8pub enum SpecialMention {
9    Here,
10    Everyone,
11}
12
13/// A user or role mention, written in config as a single-key map keyed by
14/// `user` or `role` with a string ID value.
15#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
16#[serde(rename_all = "lowercase")]
17pub enum TaggedMention {
18    Role(String),
19    User(String),
20}
21
22/// A single entry in a discord webhook's `mentions[].targets`.
23///
24/// In config, accepts either:
25/// - a bare string literal: `"here"` or `"everyone"`
26/// - a single-key map keyed by `role` or `user` with a string ID value
27#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
28#[serde(untagged)]
29pub enum MentionTarget {
30    Special(SpecialMention),
31    Tagged(TaggedMention),
32}
33
34/// A single Discord mention entry, attached to one or more event types.
35#[derive(Serialize, Clone, Debug)]
36pub struct DiscordMention {
37    /// Mention targets. Each entry is one of: `"here"`, `"everyone"`, or
38    /// a single-key map keyed by `role` or `user` with a string ID value.
39    pub targets: Vec<MentionTarget>,
40    /// Event types that trigger this mention. An empty list (or omitted
41    /// field) means the mention fires on every event in the batch.
42    #[serde(default)]
43    pub on: Vec<EventType>,
44}
45
46impl<'de> Deserialize<'de> for DiscordMention {
47    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
48    where
49        D: serde::Deserializer<'de>,
50    {
51        #[derive(Deserialize)]
52        struct Raw {
53            targets: Vec<MentionTarget>,
54            #[serde(default)]
55            on: Vec<EventType>,
56        }
57
58        let raw = Raw::deserialize(deserializer)?;
59        if raw.targets.is_empty() {
60            return Err(D::Error::custom(
61                "discord mention: `targets` must be non-empty (entries: \"here\", \"everyone\", or a single-key map keyed by \"role\" or \"user\")",
62            ));
63        }
64        Ok(DiscordMention {
65            targets: raw.targets,
66            on: raw.on,
67        })
68    }
69}
70
71#[derive(Serialize, Clone)]
72#[doc(hidden)]
73pub struct AllowedMentions {
74    /// Whitelist of role IDs that may be mentioned. Discord requires
75    /// this to be set explicitly even when the content has `<@&id>`.
76    pub roles: Vec<String>,
77    /// Whitelist of user IDs that may be mentioned. Discord requires
78    /// this to be set explicitly even when the content has `<@id>`.
79    #[serde(skip_serializing_if = "Vec::is_empty")]
80    pub users: Vec<String>,
81    /// Mention-type parse list. Discord requires `"everyone"` here to
82    /// actually fire `@here` and `@everyone` from message content.
83    #[serde(skip_serializing_if = "Vec::is_empty")]
84    pub parse: Vec<String>,
85}
86
87#[derive(Serialize, Clone)]
88#[doc(hidden)]
89pub struct DiscordEmbedField {
90    pub name: String,
91    pub value: String,
92}
93
94#[derive(Serialize, Clone)]
95#[doc(hidden)]
96pub struct DiscordEmbed {
97    pub color: i32,
98    pub timestamp: String,
99    pub fields: Vec<DiscordEmbedField>,
100    pub title: String,
101}
102
103#[derive(Serialize, Clone)]
104#[doc(hidden)]
105pub struct DiscordEmbedContent {
106    pub username: String,
107    pub avatar_url: String,
108    pub embeds: Vec<DiscordEmbed>,
109    /// Mention prefix (`<@user>`, `<@&role>`, `@here`, `@everyone`) rendered
110    /// when any embed in this payload matches a configured mention.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub content: Option<String>,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub allowed_mentions: Option<AllowedMentions>,
115}
116
117#[derive(Serialize, Deserialize, Clone)]
118pub struct DiscordWebhook {
119    /// Webhook URL
120    pub url: String,
121    /// Optional avatar URL (default [assets/logo.webp](https://raw.githubusercontent.com/dan-online/autopulse/main/assets/logo.webp))
122    pub avatar_url: Option<String>,
123    /// Optional username (default: autopulse)
124    pub username: Option<String>,
125    /// Mentions to attach to messages whose batch contains a matching event type.
126    ///
127    /// Each entry lists `targets` (any mix of `"here"`, `"everyone"`, or
128    /// a single-key map keyed by `role` or `user`) and an optional `on` filter.
129    /// An empty or missing `on` means the mention fires on every event.
130    ///
131    /// Example:
132    /// ```yml
133    /// mentions:
134    ///   - targets:
135    ///       - here
136    ///       - role: "1234567890"
137    ///       - user: "9876543210"
138    ///     on: [failed]
139    ///   - targets: [everyone]
140    /// ```
141    #[serde(default)]
142    pub mentions: Vec<DiscordMention>,
143}
144
145impl DiscordWebhook {
146    fn truncate_message(message: String, length: usize) -> String {
147        if length < 3 || message.len() <= length {
148            return message;
149        }
150
151        let cut = message.floor_char_boundary(length - 3);
152        format!("{}...", &message[..cut])
153    }
154
155    fn generate_json(
156        &self,
157        batch: &[(EventType, Option<String>, Vec<String>)],
158    ) -> DiscordEmbedContent {
159        let mut content = DiscordEmbedContent {
160            username: self
161                .username
162                .clone()
163                .unwrap_or_else(|| "autopulse".to_string()),
164            avatar_url: self.avatar_url.clone().unwrap_or_else(|| {
165                "https://raw.githubusercontent.com/dan-online/autopulse/main/assets/logo.webp"
166                    .to_string()
167            }),
168            embeds: vec![],
169            content: None,
170            allowed_mentions: None,
171        };
172
173        for (event, trigger, files) in batch {
174            let timestamp = chrono::Utc::now().to_rfc3339();
175
176            let color = match event {
177                EventType::New => 6_061_450,     // grey
178                EventType::Found => 52084,       // green
179                EventType::Failed => 16_711_680, // red
180                EventType::Processed => 39129,   // blue
181                EventType::Retrying | EventType::HashMismatch => 16_776_960,
182            };
183
184            let title = trigger.clone().map_or_else(
185                || {
186                    format!(
187                        "[{}] - {} file{} {}",
188                        event,
189                        files.len(),
190                        sify(files),
191                        event.action()
192                    )
193                },
194                |trigger| {
195                    format!(
196                        "[{}] - [{}] - {} file{} {}",
197                        event,
198                        trigger,
199                        files.len(),
200                        sify(files),
201                        event.action()
202                    )
203                },
204            );
205
206            let fields = vec![
207                DiscordEmbedField {
208                    name: "Timestamp".to_string(),
209                    value: timestamp.clone(),
210                },
211                DiscordEmbedField {
212                    name: "Files".to_string(),
213                    // value: files.join("\n"),
214                    value: Self::truncate_message(files.join("\n"), 1024),
215                },
216            ];
217
218            let embed = DiscordEmbed {
219                color,
220                timestamp,
221                fields,
222                title,
223            };
224
225            content.embeds.push(embed);
226        }
227
228        // Walk configured mentions in declared order. For each entry, the
229        // `on` filter decides whether it applies to this batch, then each
230        // target is bucketed by kind. User and role IDs are deduped
231        // insertion-stably so a multi-event batch doesn't repeat the same
232        // mention token in `content`.
233        let mut user_ids: Vec<String> = Vec::new();
234        let mut role_ids: Vec<String> = Vec::new();
235        let mut include_here = false;
236        let mut include_everyone = false;
237        let event_kinds: std::collections::HashSet<&EventType> =
238            batch.iter().map(|(e, _, _)| e).collect();
239        for mention in &self.mentions {
240            let matches =
241                mention.on.is_empty() || mention.on.iter().any(|e| event_kinds.contains(e));
242            if !matches {
243                continue;
244            }
245            for target in &mention.targets {
246                match target {
247                    MentionTarget::Special(SpecialMention::Here) => include_here = true,
248                    MentionTarget::Special(SpecialMention::Everyone) => include_everyone = true,
249                    MentionTarget::Tagged(TaggedMention::Role(id)) => {
250                        if !role_ids.iter().any(|r| r == id) {
251                            role_ids.push(id.clone());
252                        }
253                    }
254                    MentionTarget::Tagged(TaggedMention::User(id)) => {
255                        if !user_ids.iter().any(|u| u == id) {
256                            user_ids.push(id.clone());
257                        }
258                    }
259                }
260            }
261        }
262
263        if !user_ids.is_empty() || !role_ids.is_empty() || include_here || include_everyone {
264            // Order: users (most specific) -> roles -> @here -> @everyone.
265            let mut parts: Vec<String> = Vec::new();
266            parts.extend(user_ids.iter().map(|u| format!("<@{u}>")));
267            parts.extend(role_ids.iter().map(|r| format!("<@&{r}>")));
268            if include_here {
269                parts.push("@here".to_string());
270            }
271            if include_everyone {
272                parts.push("@everyone".to_string());
273            }
274            content.content = Some(parts.join(" "));
275            // Discord uses a single "everyone" parse value to authorize
276            // both @here and @everyone content tokens.
277            let parse = if include_here || include_everyone {
278                vec!["everyone".to_string()]
279            } else {
280                Vec::new()
281            };
282            content.allowed_mentions = Some(AllowedMentions {
283                roles: role_ids,
284                users: user_ids,
285                parse,
286            });
287        }
288
289        content
290    }
291
292    pub async fn send(
293        &self,
294        batch: &WebhookBatch,
295        retries: u8,
296        timeout_secs: u64,
297    ) -> anyhow::Result<()> {
298        let mut message_queue = vec![];
299
300        for chunk in batch.chunks(10) {
301            let content = self.generate_json(chunk);
302            message_queue.push(content);
303        }
304
305        transport::shared_sender(std::time::Duration::from_secs(timeout_secs))?
306            .send_json(&self.url, &message_queue, retries)
307            .await
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn truncate_ascii_under_limit() {
317        let input = "short".to_string();
318        assert_eq!(DiscordWebhook::truncate_message(input, 10), "short");
319    }
320
321    #[test]
322    fn truncate_ascii_over_limit() {
323        let input = "this is a long message".to_string();
324        let result = DiscordWebhook::truncate_message(input, 10);
325        assert_eq!(result, "this is...");
326        assert_eq!(result.len(), 10);
327    }
328
329    #[test]
330    fn truncate_multibyte_at_boundary() {
331        // '😀' is 4 bytes; cutting mid-emoji must not panic
332        let input = "abcde😀fgh".to_string();
333        // length=8 → cut at 5, but byte 5 is inside the 4-byte emoji (bytes 5..9)
334        // floor_char_boundary(5) should back up to byte 5 which is the start of 😀
335        let result = DiscordWebhook::truncate_message(input, 8);
336        assert!(result.ends_with("..."));
337        assert!(result.is_char_boundary(result.len()));
338    }
339
340    #[test]
341    fn truncate_empty_string() {
342        let result = DiscordWebhook::truncate_message(String::new(), 10);
343        assert_eq!(result, "");
344    }
345
346    #[test]
347    fn truncate_exactly_at_limit() {
348        let input = "exactly 10".to_string();
349        assert_eq!(input.len(), 10);
350        assert_eq!(DiscordWebhook::truncate_message(input, 10), "exactly 10");
351    }
352
353    use super::super::EventType;
354
355    fn role(id: &str) -> MentionTarget {
356        MentionTarget::Tagged(TaggedMention::Role(id.to_string()))
357    }
358
359    fn user(id: &str) -> MentionTarget {
360        MentionTarget::Tagged(TaggedMention::User(id.to_string()))
361    }
362
363    const HERE: MentionTarget = MentionTarget::Special(SpecialMention::Here);
364    const EVERYONE: MentionTarget = MentionTarget::Special(SpecialMention::Everyone);
365
366    fn mention(targets: Vec<MentionTarget>, on: Vec<EventType>) -> DiscordMention {
367        DiscordMention { targets, on }
368    }
369
370    fn webhook_with_mentions() -> DiscordWebhook {
371        DiscordWebhook {
372            url: "https://discord.example/webhook".to_string(),
373            avatar_url: None,
374            username: None,
375            mentions: vec![
376                mention(vec![role("111")], vec![EventType::Processed]),
377                mention(
378                    vec![role("222")],
379                    vec![EventType::Failed, EventType::HashMismatch],
380                ),
381            ],
382        }
383    }
384
385    #[test]
386    fn no_content_when_no_mentions_configured() {
387        let w = DiscordWebhook {
388            url: "x".to_string(),
389            avatar_url: None,
390            username: None,
391            mentions: vec![],
392        };
393        let payload = w.generate_json(&[(EventType::Processed, None, vec!["/a".to_string()])]);
394        assert!(payload.content.is_none());
395        assert!(payload.allowed_mentions.is_none());
396    }
397
398    #[test]
399    fn content_pings_role_for_matching_event() {
400        let w = webhook_with_mentions();
401        let payload = w.generate_json(&[(EventType::Processed, None, vec!["/a".to_string()])]);
402        assert_eq!(payload.content.as_deref(), Some("<@&111>"));
403        let am = payload.allowed_mentions.expect("allowed_mentions set");
404        assert_eq!(am.roles, vec!["111".to_string()]);
405        assert!(am.users.is_empty());
406    }
407
408    #[test]
409    fn content_does_not_ping_for_unrelated_event() {
410        let w = webhook_with_mentions();
411        let payload = w.generate_json(&[(EventType::New, None, vec!["/a".to_string()])]);
412        assert!(payload.content.is_none());
413        assert!(payload.allowed_mentions.is_none());
414    }
415
416    #[test]
417    fn content_deduplicates_roles_across_batch() {
418        let w = webhook_with_mentions();
419        let payload = w.generate_json(&[
420            (EventType::Failed, None, vec!["/a".to_string()]),
421            (EventType::HashMismatch, None, vec!["/b".to_string()]),
422        ]);
423        // Role 222 subscribes to both events; only one mention should appear.
424        assert_eq!(payload.content.as_deref(), Some("<@&222>"));
425        let am = payload.allowed_mentions.expect("allowed_mentions set");
426        assert_eq!(am.roles, vec!["222".to_string()]);
427    }
428
429    #[test]
430    fn here_target_fires_for_matching_event() {
431        let w = DiscordWebhook {
432            url: "x".to_string(),
433            avatar_url: None,
434            username: None,
435            mentions: vec![mention(vec![HERE], vec![EventType::Processed])],
436        };
437        let payload = w.generate_json(&[(EventType::Processed, None, vec!["/a".to_string()])]);
438        let content = payload.content.expect("content set");
439        assert!(content.contains("@here"), "content was {content:?}");
440        let am = payload.allowed_mentions.expect("allowed_mentions set");
441        assert!(
442            am.parse.iter().any(|p| p == "everyone"),
443            "parse was {:?}",
444            am.parse
445        );
446    }
447
448    #[test]
449    fn everyone_target_fires_for_matching_event() {
450        let w = DiscordWebhook {
451            url: "x".to_string(),
452            avatar_url: None,
453            username: None,
454            mentions: vec![mention(vec![EVERYONE], vec![EventType::Failed])],
455        };
456        let payload = w.generate_json(&[(EventType::Failed, None, vec!["/a".to_string()])]);
457        let content = payload.content.expect("content set");
458        assert!(content.contains("@everyone"), "content was {content:?}");
459        let am = payload.allowed_mentions.expect("allowed_mentions set");
460        assert!(
461            am.parse.iter().any(|p| p == "everyone"),
462            "parse was {:?}",
463            am.parse
464        );
465    }
466
467    #[test]
468    fn user_target_fires_and_whitelists_user() {
469        let w = DiscordWebhook {
470            url: "x".to_string(),
471            avatar_url: None,
472            username: None,
473            mentions: vec![mention(vec![user("999")], vec![EventType::Processed])],
474        };
475        let payload = w.generate_json(&[(EventType::Processed, None, vec!["/a".to_string()])]);
476        assert_eq!(payload.content.as_deref(), Some("<@999>"));
477        let am = payload.allowed_mentions.expect("allowed_mentions set");
478        assert_eq!(am.users, vec!["999".to_string()]);
479        assert!(am.roles.is_empty());
480    }
481
482    #[test]
483    fn user_id_dedup_across_batch() {
484        let w = DiscordWebhook {
485            url: "x".to_string(),
486            avatar_url: None,
487            username: None,
488            mentions: vec![mention(
489                vec![user("42")],
490                vec![EventType::Failed, EventType::HashMismatch],
491            )],
492        };
493        let payload = w.generate_json(&[
494            (EventType::Failed, None, vec!["/a".to_string()]),
495            (EventType::HashMismatch, None, vec!["/b".to_string()]),
496        ]);
497        assert_eq!(payload.content.as_deref(), Some("<@42>"));
498    }
499
500    #[test]
501    fn mixed_kinds_in_single_entry_render_in_blast_radius_order() {
502        let w = DiscordWebhook {
503            url: "x".to_string(),
504            avatar_url: None,
505            username: None,
506            mentions: vec![mention(
507                vec![EVERYONE, role("777"), user("42"), HERE],
508                vec![EventType::Failed],
509            )],
510        };
511        let payload = w.generate_json(&[(EventType::Failed, None, vec!["/a".to_string()])]);
512        // Render order: users -> roles -> @here -> @everyone, regardless of config order.
513        assert_eq!(
514            payload.content.as_deref(),
515            Some("<@42> <@&777> @here @everyone")
516        );
517        let am = payload.allowed_mentions.expect("allowed_mentions set");
518        assert_eq!(am.roles, vec!["777".to_string()]);
519        assert_eq!(am.users, vec!["42".to_string()]);
520        assert!(am.parse.iter().any(|p| p == "everyone"));
521    }
522
523    #[test]
524    fn multiple_roles_in_single_entry() {
525        let w = DiscordWebhook {
526            url: "x".to_string(),
527            avatar_url: None,
528            username: None,
529            mentions: vec![mention(
530                vec![role("111"), role("222")],
531                vec![EventType::Processed],
532            )],
533        };
534        let payload = w.generate_json(&[(EventType::Processed, None, vec!["/a".to_string()])]);
535        let content = payload.content.expect("content set");
536        assert!(content.contains("<@&111>"), "content was {content:?}");
537        assert!(content.contains("<@&222>"), "content was {content:?}");
538        let am = payload.allowed_mentions.expect("allowed_mentions set");
539        assert_eq!(am.roles, vec!["111".to_string(), "222".to_string()]);
540    }
541
542    #[test]
543    fn role_id_dedup_within_single_entry() {
544        let w = DiscordWebhook {
545            url: "x".to_string(),
546            avatar_url: None,
547            username: None,
548            mentions: vec![mention(
549                vec![role("111"), role("111")],
550                vec![EventType::Processed],
551            )],
552        };
553        let payload = w.generate_json(&[(EventType::Processed, None, vec!["/a".to_string()])]);
554        assert_eq!(payload.content.as_deref(), Some("<@&111>"));
555    }
556
557    #[test]
558    fn deserialize_mixed_targets() {
559        let json = r#"{
560            "targets": ["here", { "role": "111" }, { "user": "222" }, "everyone"],
561            "on": ["failed"]
562        }"#;
563        let m: DiscordMention = serde_json::from_str(json).unwrap();
564        assert_eq!(
565            m.targets,
566            vec![
567                MentionTarget::Special(SpecialMention::Here),
568                MentionTarget::Tagged(TaggedMention::Role("111".to_string())),
569                MentionTarget::Tagged(TaggedMention::User("222".to_string())),
570                MentionTarget::Special(SpecialMention::Everyone),
571            ]
572        );
573        assert_eq!(m.on, vec![EventType::Failed]);
574    }
575
576    #[test]
577    fn deserialize_rejects_empty_targets() {
578        let json = r#"{ "targets": [], "on": ["processed"] }"#;
579        let err = serde_json::from_str::<DiscordMention>(json).unwrap_err();
580        let msg = err.to_string();
581        assert!(
582            msg.contains("non-empty"),
583            "expected validation error mentioning 'non-empty', got: {msg}"
584        );
585    }
586
587    #[test]
588    fn deserialize_rejects_missing_targets() {
589        let json = r#"{ "on": ["processed"] }"#;
590        let err = serde_json::from_str::<DiscordMention>(json).unwrap_err();
591        // serde itself reports the missing field; we don't customize this message.
592        let msg = err.to_string();
593        assert!(
594            msg.contains("targets"),
595            "expected error mentioning `targets`, got: {msg}"
596        );
597    }
598
599    #[test]
600    fn deserialize_rejects_unknown_special() {
601        // "channel" is not a recognized special; should fail to match either
602        // variant of the untagged enum.
603        let json = r#"{ "targets": ["channel"] }"#;
604        let err = serde_json::from_str::<DiscordMention>(json).unwrap_err();
605        assert!(!err.to_string().is_empty());
606    }
607
608    #[test]
609    fn deserialize_rejects_tagged_with_unknown_kind() {
610        let json = r#"{ "targets": [{ "channel": "1" }] }"#;
611        let err = serde_json::from_str::<DiscordMention>(json).unwrap_err();
612        assert!(!err.to_string().is_empty());
613    }
614
615    #[test]
616    fn event_type_deserializes_from_snake_case() {
617        let v: EventType = serde_json::from_str("\"hash_mismatch\"").unwrap();
618        assert_eq!(v, EventType::HashMismatch);
619        let v: EventType = serde_json::from_str("\"processed\"").unwrap();
620        assert_eq!(v, EventType::Processed);
621    }
622
623    #[test]
624    fn role_with_no_on_pings_for_every_event() {
625        let w = DiscordWebhook {
626            url: "x".to_string(),
627            avatar_url: None,
628            username: None,
629            mentions: vec![mention(vec![role("111")], vec![])],
630        };
631        let payload = w.generate_json(&[(EventType::New, None, vec!["/a".to_string()])]);
632        assert_eq!(payload.content.as_deref(), Some("<@&111>"));
633        let am = payload.allowed_mentions.expect("allowed_mentions set");
634        assert_eq!(am.roles, vec!["111".to_string()]);
635    }
636
637    #[test]
638    fn here_with_no_on_pings_for_every_event() {
639        let w = DiscordWebhook {
640            url: "x".to_string(),
641            avatar_url: None,
642            username: None,
643            mentions: vec![mention(vec![HERE], vec![])],
644        };
645        let payload = w.generate_json(&[(EventType::New, None, vec!["/a".to_string()])]);
646        let content = payload.content.expect("content set");
647        assert!(content.contains("@here"), "content was {content:?}");
648        let am = payload.allowed_mentions.expect("allowed_mentions set");
649        assert!(
650            am.parse.iter().any(|p| p == "everyone"),
651            "parse was {:?}",
652            am.parse
653        );
654    }
655
656    #[test]
657    fn mixed_default_and_specific_on_both_fire() {
658        let w = DiscordWebhook {
659            url: "x".to_string(),
660            avatar_url: None,
661            username: None,
662            mentions: vec![
663                mention(vec![role("111")], vec![]),
664                mention(vec![role("222")], vec![EventType::Failed]),
665            ],
666        };
667        let payload = w.generate_json(&[(EventType::Failed, None, vec!["/a".to_string()])]);
668        let content = payload.content.expect("content set");
669        assert!(content.contains("<@&111>"), "content was {content:?}");
670        assert!(content.contains("<@&222>"), "content was {content:?}");
671        let am = payload.allowed_mentions.expect("allowed_mentions set");
672        assert!(am.roles.contains(&"111".to_string()));
673        assert!(am.roles.contains(&"222".to_string()));
674    }
675
676    #[test]
677    fn event_type_snake_case_matches_key_method() {
678        for variant in [
679            EventType::New,
680            EventType::HashMismatch,
681            EventType::Found,
682            EventType::Retrying,
683            EventType::Processed,
684            EventType::Failed,
685        ] {
686            let json = serde_json::to_string(&variant).unwrap();
687            assert_eq!(
688                json.trim_matches('"'),
689                variant.key(),
690                "serde wire format must equal key() for {variant:?}"
691            );
692        }
693    }
694}