1use super::{transport, EventType, WebhookBatch};
2use autopulse_utils::sify;
3use serde::{de::Error as _, Deserialize, Serialize};
4
5#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
7#[serde(rename_all = "lowercase")]
8pub enum SpecialMention {
9 Here,
10 Everyone,
11}
12
13#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
16#[serde(rename_all = "lowercase")]
17pub enum TaggedMention {
18 Role(String),
19 User(String),
20}
21
22#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
28#[serde(untagged)]
29pub enum MentionTarget {
30 Special(SpecialMention),
31 Tagged(TaggedMention),
32}
33
34#[derive(Serialize, Clone, Debug)]
36pub struct DiscordMention {
37 pub targets: Vec<MentionTarget>,
40 #[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 pub roles: Vec<String>,
77 #[serde(skip_serializing_if = "Vec::is_empty")]
80 pub users: Vec<String>,
81 #[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 #[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 pub url: String,
121 pub avatar_url: Option<String>,
123 pub username: Option<String>,
125 #[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, EventType::Found => 52084, EventType::Failed => 16_711_680, EventType::Processed => 39129, 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: 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 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 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 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 let input = "abcde😀fgh".to_string();
333 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 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 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 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 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}