1use regex::Regex;
9use serenity::all::{
10 ChannelId, ChannelType, Context, CreateEmbed, CreateEmbedAuthor, CreateEmbedFooter,
11 GuildChannel, GuildId, Message, MessageId, PermissionOverwrite, PermissionOverwriteType,
12 Permissions, RoleId,
13};
14use std::collections::{HashMap, HashSet};
15use std::sync::LazyLock;
16
17use super::{ExpandContext, ExpandError, ExpandedContent, LinkExpander};
18use crate::cache::CacheArgs;
19use crate::config::BabyriteConfig;
20use serenity::futures::future::join_all;
21
22pub static MESSAGE_LINK_REGEX: LazyLock<Regex> = LazyLock::new(|| {
26 Regex::new(r"https://(?:ptb\.|canary\.)?discord\.com/channels/(\d+)/(\d+)/(\d+)").unwrap()
27});
28
29#[derive(Debug)]
31pub struct MessageLinkIDs {
32 pub guild_id: GuildId,
34 pub channel_id: ChannelId,
36 pub message_id: MessageId,
38}
39
40#[derive(Debug)]
42pub struct Preview {
43 pub message: Message,
45 pub channel: GuildChannel,
47}
48
49pub struct DiscordExpander;
51
52#[serenity::async_trait]
53impl LinkExpander for DiscordExpander {
54 fn enabled(&self, _config: &BabyriteConfig) -> bool {
56 true
57 }
58
59 #[cfg_attr(coverage_nightly, coverage(off))]
71 async fn expand_all(&self, cx: &ExpandContext<'_>) -> Vec<ExpandedContent> {
72 let links = MessageLinkIDs::parse_all(&cx.message.content);
73 if links.is_empty() {
74 return Vec::new();
75 }
76 tracing::debug!(count = links.len(), "parsed Discord links");
77
78 let source_channel = match (CacheArgs {
79 guild_id: cx.guild_id,
80 channel_id: cx.message.channel_id,
81 })
82 .get(cx.ctx)
83 .await
84 {
85 Ok(channel) => channel,
86 Err(e) => {
87 tracing::error!(error = %e, "failed to resolve source channel");
88 return Vec::new();
89 }
90 };
91
92 join_all(links.iter().map(|ids| ids.fetch(cx.ctx, &source_channel)))
93 .await
94 .into_iter()
95 .filter_map(|result| match result {
96 Ok(content) => Some(content),
97 Err(ExpandError::Discord(e)) if e.is_policy_rejection() => {
98 tracing::debug!(error = %e, "skipped Discord link by visibility policy");
99 None
100 }
101 Err(e) => {
102 tracing::error!(error = %e, "failed to expand Discord link");
103 None
104 }
105 })
106 .collect()
107 }
108}
109
110#[derive(thiserror::Error, Debug)]
112pub enum PreviewError {
113 #[error("The link points to another guild, which cannot be expanded.")]
115 CrossGuild,
116 #[error("Failed to retrieve from cache.")]
118 Cache,
119 #[error("NSFW content previews are not permitted, but the channel is marked as NSFW.")]
121 Nsfw,
122 #[error("The channel is a private channel or private thread.")]
124 Permission,
125 #[allow(clippy::enum_variant_names)]
129 #[error(transparent)]
130 SerenityError(#[from] Box<serenity::Error>),
131}
132
133impl PreviewError {
134 pub fn is_policy_rejection(&self) -> bool {
140 match self {
143 Self::CrossGuild | Self::Nsfw | Self::Permission => true,
144 Self::Cache | Self::SerenityError(_) => false,
145 }
146 }
147}
148
149impl MessageLinkIDs {
150 pub fn parse_all(text: &str) -> Vec<MessageLinkIDs> {
156 super::parse_links(text, &MESSAGE_LINK_REGEX, |captures| {
157 Some(MessageLinkIDs {
158 guild_id: GuildId::new(captures.get(1)?.as_str().parse().ok()?),
159 channel_id: ChannelId::new(captures.get(2)?.as_str().parse().ok()?),
160 message_id: MessageId::new(captures.get(3)?.as_str().parse().ok()?),
161 })
162 })
163 }
164
165 #[cfg_attr(coverage_nightly, coverage(off))]
171 #[tracing::instrument(
172 skip(self, ctx, source_channel),
173 fields(
174 guild_id = %self.guild_id,
175 channel_id = %self.channel_id,
176 message_id = %self.message_id,
177 )
178 )]
179 pub async fn fetch(
180 &self,
181 ctx: &Context,
182 source_channel: &GuildChannel,
183 ) -> Result<ExpandedContent, ExpandError> {
184 let Preview { message, channel } = Preview::get(self, ctx, source_channel).await?;
185
186 Ok(ExpandedContent::Embed(Box::new(preview_embed(
187 &message, &channel,
188 ))))
189 }
190}
191
192const PREVIEW_EMBED_COLOUR: u32 = 0x7A4AFF;
194
195fn preview_embed(message: &Message, channel: &GuildChannel) -> CreateEmbed {
200 let mut author = CreateEmbedAuthor::new(message.author.name.as_str());
201 if let Some(avatar_url) = message.author.avatar_url() {
202 author = author.icon_url(avatar_url);
203 }
204
205 let mut embed = CreateEmbed::new()
206 .description(message.content.as_str())
207 .author(author)
208 .footer(CreateEmbedFooter::new(channel.name.as_str()))
209 .timestamp(message.timestamp)
210 .colour(PREVIEW_EMBED_COLOUR);
211
212 if let Some(attachment) = message.attachments.first() {
213 embed = embed.image(attachment.url.as_str());
214 }
215
216 embed
217}
218
219fn is_thread(kind: ChannelType) -> bool {
225 matches!(
226 kind,
227 ChannelType::NewsThread | ChannelType::PublicThread | ChannelType::PrivateThread
228 )
229}
230
231fn has_member_view_deny(overwrites: &[PermissionOverwrite]) -> bool {
236 overwrites.iter().any(|ow| {
237 matches!(ow.kind, PermissionOverwriteType::Member(_))
238 && ow.deny.contains(Permissions::VIEW_CHANNEL)
239 })
240}
241
242fn has_member_view_allow(overwrites: &[PermissionOverwrite]) -> bool {
248 overwrites.iter().any(|ow| {
249 matches!(ow.kind, PermissionOverwriteType::Member(_))
250 && ow.allow.contains(Permissions::VIEW_CHANNEL)
251 })
252}
253
254fn member_granted_source_is_safe(
266 source_overwrites: &[PermissionOverwrite],
267 dest_roles: &HashSet<RoleId>,
268 everyone_role_id: RoleId,
269) -> bool {
270 !has_member_view_allow(source_overwrites) || dest_roles.contains(&everyone_role_id)
271}
272
273fn is_cross_guild(link: GuildId, source: GuildId) -> bool {
280 link != source
281}
282
283fn requires_visibility_check(target: ChannelId, source: ChannelId) -> bool {
291 target != source
292}
293
294fn viewing_roles(
302 overwrites: &[PermissionOverwrite],
303 role_perms: &HashMap<RoleId, Permissions>,
304 everyone_role_id: RoleId,
305) -> HashSet<RoleId> {
306 let everyone_base = role_perms
307 .get(&everyone_role_id)
308 .copied()
309 .unwrap_or_else(Permissions::empty);
310
311 let overwrite_by_role: HashMap<RoleId, (Permissions, Permissions)> = overwrites
312 .iter()
313 .filter_map(|ow| match ow.kind {
314 PermissionOverwriteType::Role(id) => Some((id, (ow.allow, ow.deny))),
315 _ => None,
316 })
317 .collect();
318
319 let mut set = HashSet::new();
320 for (&role_id, &perms) in role_perms {
321 let base = everyone_base | perms;
322 if base.contains(Permissions::ADMINISTRATOR) {
323 set.insert(role_id);
324 continue;
325 }
326
327 let mut allowed = base.contains(Permissions::VIEW_CHANNEL);
328 for target in [everyone_role_id, role_id] {
329 if let Some(&(allow, deny)) = overwrite_by_role.get(&target) {
330 if deny.contains(Permissions::VIEW_CHANNEL) {
331 allowed = false;
332 }
333 if allow.contains(Permissions::VIEW_CHANNEL) {
334 allowed = true;
335 }
336 }
337 }
338
339 if allowed {
340 set.insert(role_id);
341 }
342 }
343 set
344}
345
346#[cfg_attr(coverage_nightly, coverage(off))]
353async fn permission_channel(
354 channel: &GuildChannel,
355 ctx: &Context,
356) -> Result<GuildChannel, PreviewError> {
357 if !is_thread(channel.kind) {
358 return Ok(channel.clone());
359 }
360
361 let parent_id = channel.parent_id.ok_or(PreviewError::Permission)?;
362 CacheArgs {
363 guild_id: channel.guild_id,
364 channel_id: parent_id,
365 }
366 .get(ctx)
367 .await
368 .map_err(|_| PreviewError::Cache)
369}
370
371#[cfg_attr(coverage_nightly, coverage(off))]
385async fn check_visibility(
386 channel: &GuildChannel,
387 source_channel: &GuildChannel,
388 ctx: &Context,
389) -> Result<(), PreviewError> {
390 if matches!(
395 channel.kind,
396 ChannelType::PrivateThread | ChannelType::Private
397 ) {
398 tracing::debug!(kind = ?channel.kind, "rejected: private channel or thread");
399 return Err(PreviewError::Permission);
400 }
401
402 let (dest_perm, source_perm) = tokio::try_join!(
406 permission_channel(channel, ctx),
407 permission_channel(source_channel, ctx),
408 )?;
409
410 if has_member_view_deny(&dest_perm.permission_overwrites) {
413 tracing::debug!("rejected: target has a per-member VIEW_CHANNEL deny");
414 return Err(PreviewError::Permission);
415 }
416
417 let guild_id = source_channel.guild_id;
418 let everyone_role_id = RoleId::new(guild_id.get());
419 let role_perms: HashMap<RoleId, Permissions> = {
423 let guild = ctx.cache.guild(guild_id).ok_or(PreviewError::Permission)?;
424 guild
425 .roles
426 .iter()
427 .map(|(&id, role)| (id, role.permissions))
428 .collect()
429 };
430
431 let dest_roles = viewing_roles(
432 &dest_perm.permission_overwrites,
433 &role_perms,
434 everyone_role_id,
435 );
436 let source_roles = viewing_roles(
437 &source_perm.permission_overwrites,
438 &role_perms,
439 everyone_role_id,
440 );
441 if !member_granted_source_is_safe(
442 &source_perm.permission_overwrites,
443 &dest_roles,
444 everyone_role_id,
445 ) {
446 tracing::debug!(
447 "rejected: source grants access per member and the target is not visible to everyone"
448 );
449 return Err(PreviewError::Permission);
450 }
451
452 if !source_roles.is_subset(&dest_roles) {
453 tracing::debug!(
454 source_roles = source_roles.len(),
455 dest_roles = dest_roles.len(),
456 "rejected: source channel is more visible than the target"
457 );
458 return Err(PreviewError::Permission);
459 }
460
461 Ok(())
462}
463
464impl Preview {
465 #[cfg_attr(coverage_nightly, coverage(off))]
486 #[tracing::instrument(
487 skip(args, ctx, source_channel),
488 fields(
489 guild_id = %args.guild_id,
490 channel_id = %args.channel_id,
491 message_id = %args.message_id,
492 )
493 )]
494 async fn get(
495 args: &MessageLinkIDs,
496 ctx: &Context,
497 source_channel: &GuildChannel,
498 ) -> Result<Preview, PreviewError> {
499 if is_cross_guild(args.guild_id, source_channel.guild_id) {
500 tracing::debug!(link_guild_id = %args.guild_id, "rejected: cross-guild link");
501 return Err(PreviewError::CrossGuild);
502 }
503
504 let caches = CacheArgs {
505 guild_id: source_channel.guild_id,
509 channel_id: args.channel_id,
510 };
511
512 let channel = caches.get(ctx).await.map_err(|_| PreviewError::Cache)?;
513 tracing::debug!(kind = ?channel.kind, nsfw = channel.nsfw, "resolved target channel");
514
515 let age_gate = permission_channel(&channel, ctx).await?;
520 if age_gate.nsfw {
521 tracing::debug!("rejected: target channel is NSFW");
522 return Err(PreviewError::Nsfw);
523 }
524
525 if requires_visibility_check(args.channel_id, source_channel.id) {
532 check_visibility(&channel, source_channel, ctx).await?;
533 }
534
535 let started = std::time::Instant::now();
536 let message = channel
537 .message(&ctx.http, args.message_id)
538 .await
539 .map_err(Box::new)?;
540 tracing::debug!(
541 elapsed_ms = started.elapsed().as_millis(),
542 "fetched linked message"
543 );
544 Ok(Preview { message, channel })
545 }
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551
552 #[test]
553 fn parse_standard_link() {
554 let text = "https://discord.com/channels/123456789/987654321/111111111";
555 let results = MessageLinkIDs::parse_all(text);
556 assert_eq!(results.len(), 1);
557 assert_eq!(results[0].guild_id, GuildId::new(123456789));
558 assert_eq!(results[0].channel_id, ChannelId::new(987654321));
559 assert_eq!(results[0].message_id, MessageId::new(111111111));
560 }
561
562 #[test]
563 fn parse_ptb_link() {
564 let text = "https://ptb.discord.com/channels/123/456/789";
565 let results = MessageLinkIDs::parse_all(text);
566 assert_eq!(results.len(), 1);
567 assert_eq!(results[0].guild_id, GuildId::new(123));
568 }
569
570 #[test]
571 fn parse_canary_link() {
572 let text = "https://canary.discord.com/channels/123/456/789";
573 let results = MessageLinkIDs::parse_all(text);
574 assert_eq!(results.len(), 1);
575 assert_eq!(results[0].guild_id, GuildId::new(123));
576 }
577
578 #[test]
579 fn parse_multiple_links() {
580 let text = "https://discord.com/channels/1/2/3 and https://discord.com/channels/4/5/6";
581 let results = MessageLinkIDs::parse_all(text);
582 assert_eq!(results.len(), 2);
583 assert_eq!(results[0].guild_id, GuildId::new(1));
584 assert_eq!(results[1].guild_id, GuildId::new(4));
585 }
586
587 #[test]
588 fn parse_deduplicates() {
589 let text = "https://discord.com/channels/1/2/3 https://discord.com/channels/1/2/3";
590 let results = MessageLinkIDs::parse_all(text);
591 assert_eq!(results.len(), 1);
592 }
593
594 #[test]
595 fn parse_limits_to_three() {
596 let text = "\
597 https://discord.com/channels/1/2/3 \
598 https://discord.com/channels/4/5/6 \
599 https://discord.com/channels/7/8/9 \
600 https://discord.com/channels/10/11/12";
601 let results = MessageLinkIDs::parse_all(text);
602 assert_eq!(results.len(), 3);
603 }
604
605 #[test]
606 fn parse_no_match() {
607 let text = "Just some regular text";
608 let results = MessageLinkIDs::parse_all(text);
609 assert!(results.is_empty());
610 }
611
612 #[test]
613 fn parse_ignores_invalid_url() {
614 let text = "https://notdiscord.com/channels/1/2/3";
616 let results = MessageLinkIDs::parse_all(text);
617 assert!(results.is_empty());
618 }
619
620 #[test]
621 fn parse_ignores_angle_bracket_link() {
622 let text = "<https://discord.com/channels/123/456/789>";
623 let results = MessageLinkIDs::parse_all(text);
624 assert!(results.is_empty());
625 }
626
627 #[test]
628 fn parse_mixed_with_text() {
629 let text = "Hey check this out https://discord.com/channels/1/2/3 pretty cool right?";
630 let results = MessageLinkIDs::parse_all(text);
631 assert_eq!(results.len(), 1);
632 assert_eq!(results[0].message_id, MessageId::new(3));
633 }
634
635 use serenity::all::UserId;
638
639 fn role_ow(id: u64, allow_view: bool, deny_view: bool) -> PermissionOverwrite {
641 PermissionOverwrite {
642 allow: if allow_view {
643 Permissions::VIEW_CHANNEL
644 } else {
645 Permissions::empty()
646 },
647 deny: if deny_view {
648 Permissions::VIEW_CHANNEL
649 } else {
650 Permissions::empty()
651 },
652 kind: PermissionOverwriteType::Role(RoleId::new(id)),
653 }
654 }
655
656 fn member_ow(id: u64, allow_view: bool, deny_view: bool) -> PermissionOverwrite {
658 let bit = |set: bool| {
659 if set {
660 Permissions::VIEW_CHANNEL
661 } else {
662 Permissions::empty()
663 }
664 };
665 PermissionOverwrite {
666 allow: bit(allow_view),
667 deny: bit(deny_view),
668 kind: PermissionOverwriteType::Member(UserId::new(id)),
669 }
670 }
671
672 const EVERYONE: RoleId = RoleId::new(1);
673 const MEMBER: RoleId = RoleId::new(100);
674 const SPECIAL: RoleId = RoleId::new(200);
675 const ADMIN: RoleId = RoleId::new(300);
676
677 #[test]
678 fn thread_kinds_detected() {
679 assert!(is_thread(ChannelType::PublicThread));
680 assert!(is_thread(ChannelType::NewsThread));
681 assert!(is_thread(ChannelType::PrivateThread));
682 assert!(!is_thread(ChannelType::Text));
683 assert!(!is_thread(ChannelType::Voice));
684 }
685
686 #[test]
687 fn member_view_deny_detected() {
688 assert!(has_member_view_deny(&[member_ow(5, false, true)]));
689 assert!(!has_member_view_deny(&[role_ow(1, false, true)]));
691 assert!(!has_member_view_deny(&[member_ow(5, true, false)]));
693 assert!(!has_member_view_deny(&[]));
694 }
695
696 #[test]
697 fn member_view_allow_detected() {
698 assert!(has_member_view_allow(&[member_ow(5, true, false)]));
699 assert!(!has_member_view_allow(&[role_ow(1, true, false)]));
701 assert!(!has_member_view_allow(&[member_ow(5, false, true)]));
703 assert!(!has_member_view_allow(&[]));
704 }
705
706 #[test]
707 fn member_granted_source_may_only_expand_public_targets() {
708 let public_target = HashSet::from([EVERYONE, MEMBER]);
709 let restricted_target = HashSet::from([MEMBER]);
710 let granted = [member_ow(5, true, false)];
711
712 assert!(member_granted_source_is_safe(
714 &granted,
715 &public_target,
716 EVERYONE
717 ));
718 assert!(!member_granted_source_is_safe(
720 &granted,
721 &restricted_target,
722 EVERYONE
723 ));
724 }
725
726 #[test]
727 fn role_only_source_is_left_to_the_subset_check() {
728 let restricted_target = HashSet::from([MEMBER]);
729 assert!(member_granted_source_is_safe(
732 &[],
733 &restricted_target,
734 EVERYONE
735 ));
736 assert!(member_granted_source_is_safe(
737 &[role_ow(MEMBER.get(), true, false)],
738 &restricted_target,
739 EVERYONE
740 ));
741 assert!(member_granted_source_is_safe(
743 &[member_ow(5, false, true)],
744 &restricted_target,
745 EVERYONE
746 ));
747 }
748
749 #[test]
750 fn same_channel_skips_visibility_check() {
751 let chan = ChannelId::new(42);
752 assert!(!requires_visibility_check(chan, chan));
754 assert!(requires_visibility_check(chan, ChannelId::new(99)));
756 }
757
758 #[test]
759 fn cross_guild_links_are_rejected() {
760 let guild = GuildId::new(7);
761 assert!(!is_cross_guild(guild, guild));
763 assert!(is_cross_guild(GuildId::new(8), guild));
765 }
766
767 #[test]
768 fn only_visibility_rejections_count_as_policy() {
769 assert!(PreviewError::CrossGuild.is_policy_rejection());
771 assert!(PreviewError::Nsfw.is_policy_rejection());
772 assert!(PreviewError::Permission.is_policy_rejection());
773 assert!(!PreviewError::Cache.is_policy_rejection());
775 assert!(
776 !PreviewError::SerenityError(Box::new(serenity::Error::Other("boom")))
777 .is_policy_rejection()
778 );
779 }
780
781 #[test]
782 fn public_channels_are_subsets() {
783 let mut roles = HashMap::new();
785 roles.insert(EVERYONE, Permissions::VIEW_CHANNEL);
786 roles.insert(MEMBER, Permissions::empty());
787
788 let viewing = viewing_roles(&[], &roles, EVERYONE);
789 assert!(viewing.contains(&EVERYONE));
790 assert!(viewing.contains(&MEMBER));
791 assert!(viewing.is_subset(&viewing));
793 }
794
795 #[test]
796 fn role_gate_allows_matching_member_role() {
797 let mut roles = HashMap::new();
799 roles.insert(EVERYONE, Permissions::empty());
800 roles.insert(MEMBER, Permissions::empty());
801
802 let ow = [
803 role_ow(EVERYONE.get(), false, true),
804 role_ow(MEMBER.get(), true, false),
805 ];
806 let viewing = viewing_roles(&ow, &roles, EVERYONE);
807
808 assert!(!viewing.contains(&EVERYONE));
809 assert!(viewing.contains(&MEMBER));
810
811 let source = viewing_roles(&ow, &roles, EVERYONE);
813 assert!(source.is_subset(&viewing));
814 }
815
816 #[test]
817 fn narrower_target_is_rejected() {
818 let mut roles = HashMap::new();
819 roles.insert(EVERYONE, Permissions::empty());
820 roles.insert(MEMBER, Permissions::empty());
821 roles.insert(SPECIAL, Permissions::empty());
822
823 let source_ow = [
825 role_ow(EVERYONE.get(), false, true),
826 role_ow(MEMBER.get(), true, false),
827 ];
828 let source = viewing_roles(&source_ow, &roles, EVERYONE);
829
830 let dest_ow = [
832 role_ow(EVERYONE.get(), false, true),
833 role_ow(SPECIAL.get(), true, false),
834 ];
835 let dest = viewing_roles(&dest_ow, &roles, EVERYONE);
836
837 assert!(source.contains(&MEMBER));
838 assert!(!dest.contains(&MEMBER));
839 assert!(!source.is_subset(&dest));
841 }
842
843 #[test]
844 fn administrator_always_views() {
845 let mut roles = HashMap::new();
846 roles.insert(EVERYONE, Permissions::empty());
847 roles.insert(ADMIN, Permissions::ADMINISTRATOR);
848
849 let ow = [role_ow(EVERYONE.get(), false, true)];
851 let viewing = viewing_roles(&ow, &roles, EVERYONE);
852 assert!(viewing.contains(&ADMIN));
853 assert!(!viewing.contains(&EVERYONE));
854 }
855
856 #[test]
857 fn other_role_overwrite_does_not_affect_role() {
858 let mut roles = HashMap::new();
859 roles.insert(EVERYONE, Permissions::VIEW_CHANNEL);
860 roles.insert(MEMBER, Permissions::empty());
861 roles.insert(SPECIAL, Permissions::empty());
862
863 let ow = [role_ow(SPECIAL.get(), false, true)];
865 let viewing = viewing_roles(&ow, &roles, EVERYONE);
866 assert!(viewing.contains(&MEMBER));
867 assert!(!viewing.contains(&SPECIAL));
868 }
869
870 #[test]
871 fn missing_everyone_role_defaults_to_no_base() {
872 let mut roles = HashMap::new();
875 roles.insert(MEMBER, Permissions::empty());
876
877 let viewing = viewing_roles(&[], &roles, EVERYONE);
878 assert!(!viewing.contains(&MEMBER));
879
880 let ow = [role_ow(MEMBER.get(), true, false)];
882 let viewing = viewing_roles(&ow, &roles, EVERYONE);
883 assert!(viewing.contains(&MEMBER));
884 }
885
886 use serenity::all::Timestamp;
889
890 fn preview_parts(avatar: Option<&str>) -> (Message, GuildChannel) {
891 let mut message = Message::default();
892 message.content = "quoted content".to_string();
893 message.author.name = "author".to_string();
894 message.author.avatar = avatar.map(|hash| hash.parse().unwrap());
895 message.timestamp = Timestamp::parse("2024-01-01T00:00:00Z").unwrap();
896
897 let mut channel = GuildChannel::default();
898 channel.name = "general".to_string();
899
900 (message, channel)
901 }
902
903 #[test]
904 fn embed_carries_the_quoted_message_and_its_origin() {
905 let (message, channel) = preview_parts(None);
906
907 let embed = preview_embed(&message, &channel);
908
909 assert_eq!(
910 embed,
911 CreateEmbed::new()
912 .description("quoted content")
913 .author(CreateEmbedAuthor::new("author"))
914 .footer(CreateEmbedFooter::new("general"))
915 .timestamp(message.timestamp)
916 .colour(PREVIEW_EMBED_COLOUR)
917 );
918 }
919
920 #[test]
921 fn an_author_without_an_avatar_gets_no_icon_url() {
922 let (message, channel) = preview_parts(None);
923 let without_avatar = preview_embed(&message, &channel);
924
925 let (message, channel) = preview_parts(Some("a_00000000000000000000000000000000"));
926 let with_avatar = preview_embed(&message, &channel);
927
928 assert_ne!(without_avatar, with_avatar);
929 assert!(format!("{with_avatar:?}").contains("a_00000000000000000000000000000000"));
930 }
931}