Skip to main content

babyrite/expand/
discord.rs

1//! Discord message link expansion.
2//!
3//! This module provides functionality for parsing Discord message links
4//! and generating embed previews of the linked messages.
5//!
6//! Migrated from `preview.rs` with support for multiple link expansion.
7
8use 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
22/// Regex pattern for matching Discord message links.
23///
24/// Supports production, PTB, and Canary Discord URLs.
25pub 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/// Parsed IDs from a Discord message link.
30#[derive(Debug)]
31pub struct MessageLinkIDs {
32    /// The guild ID from the message link.
33    pub guild_id: GuildId,
34    /// The channel ID from the message link.
35    pub channel_id: ChannelId,
36    /// The message ID from the message link.
37    pub message_id: MessageId,
38}
39
40/// A preview containing the message and its channel.
41#[derive(Debug)]
42pub struct Preview {
43    /// The message to preview.
44    pub message: Message,
45    /// The channel containing the message.
46    pub channel: GuildChannel,
47}
48
49/// Discord message link expander.
50pub struct DiscordExpander;
51
52#[serenity::async_trait]
53impl LinkExpander for DiscordExpander {
54    /// Discord link expansion is the bot's core function and has no feature flag.
55    fn enabled(&self, _config: &BabyriteConfig) -> bool {
56        true
57    }
58
59    /// Expands Discord message links into embed previews.
60    ///
61    /// The source channel is resolved once — the expanded preview is posted
62    /// there, so it is needed to verify each link target is at least as visible
63    /// as that channel. If it cannot be resolved, Discord expansion is skipped
64    /// entirely (other expanders are unaffected).
65    ///
66    /// Whether a link may be expanded at all is decided by [`Preview::get`].
67    /// The rejections it reports are expected outcomes and are logged at
68    /// `debug`; only genuine failures reach `error` (see
69    /// [`PreviewError::is_policy_rejection`]).
70    #[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/// Errors that can occur when generating a Discord message preview.
111#[derive(thiserror::Error, Debug)]
112pub enum PreviewError {
113    /// The link points into a guild other than the one it was posted in.
114    #[error("The link points to another guild, which cannot be expanded.")]
115    CrossGuild,
116    /// Failed to retrieve channel information from cache.
117    #[error("Failed to retrieve from cache.")]
118    Cache,
119    /// The target channel is marked as NSFW.
120    #[error("NSFW content previews are not permitted, but the channel is marked as NSFW.")]
121    Nsfw,
122    /// The target channel is private or a private thread.
123    #[error("The channel is a private channel or private thread.")]
124    Permission,
125    /// An error occurred while communicating with Discord.
126    // Boxed: `serenity::Error` is 136 bytes and would otherwise dominate the
127    // size of every `Result` in this module (`clippy::result_large_err`).
128    #[allow(clippy::enum_variant_names)]
129    #[error(transparent)]
130    SerenityError(#[from] Box<serenity::Error>),
131}
132
133impl PreviewError {
134    /// Whether this is the visibility policy working as designed rather than a
135    /// failure.
136    ///
137    /// Rejections happen during normal use, so callers log them at `debug` and
138    /// keep `error` for cases that need attention.
139    pub fn is_policy_rejection(&self) -> bool {
140        // Matched exhaustively rather than with a `_` arm so that a new variant
141        // has to declare its severity instead of silently counting as a failure.
142        match self {
143            Self::CrossGuild | Self::Nsfw | Self::Permission => true,
144            Self::Cache | Self::SerenityError(_) => false,
145        }
146    }
147}
148
149impl MessageLinkIDs {
150    /// Parses all Discord message links from the given text.
151    ///
152    /// Returns a `Vec<MessageLinkIDs>` containing all valid message links found.
153    /// The shared link policy applies (see [`super::parse_links`]): angle-bracket
154    /// wrapped and duplicate URLs are ignored, and at most 3 links are returned.
155    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    /// Fetches the linked message and returns an embed preview.
166    ///
167    /// `source_channel` is the channel where the request originated. It is used to
168    /// ensure the linked content is not exposed to members who could not otherwise
169    /// view it (see [`Preview::get`]).
170    #[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
192/// Accent colour of a message preview embed.
193const PREVIEW_EMBED_COLOUR: u32 = 0x7A4AFF;
194
195/// Renders a fetched message as the embed posted in the requester's channel.
196///
197/// Optional parts of the source message stay unset rather than being sent as an
198/// empty string: Discord rejects `""` where it expects a URL.
199fn 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
219/// Returns `true` for thread channel types.
220///
221/// Threads do not carry their own permission overwrites; their visibility
222/// follows the parent channel. This is used to decide whether visibility must be
223/// resolved against the parent (see [`permission_channel`]).
224fn is_thread(kind: ChannelType) -> bool {
225    matches!(
226        kind,
227        ChannelType::NewsThread | ChannelType::PublicThread | ChannelType::PrivateThread
228    )
229}
230
231/// Returns `true` if any per-member overwrite denies `VIEW_CHANNEL`.
232///
233/// Per-member overwrites cannot be captured by the role-set comparison in
234/// [`viewing_roles`], so their presence forces a conservative rejection.
235fn 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
242/// Returns `true` if any per-member overwrite grants `VIEW_CHANNEL`.
243///
244/// This is how Discord represents a channel made private by adding individual
245/// users. Access granted this way is invisible to [`viewing_roles`], so its
246/// presence means the role set understates who can see the channel.
247fn 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
254/// Returns `true` when a source channel that grants access through per-member
255/// overwrites may still be expanded into the target described by `dest_roles`.
256///
257/// Members holding such a grant are not represented in the source's role set, so
258/// the subset comparison in [`check_visibility`] says nothing about whether they
259/// can view the target. The only target they are provably allowed to see is one
260/// `@everyone` can view — which, since [`viewing_roles`] treats `@everyone` as an
261/// ordinary role, is exactly `dest_roles` containing it.
262///
263/// Sources without such a grant are fully described by their role set and are
264/// left to the subset comparison.
265fn 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
273/// Returns `true` when a link crosses a guild boundary.
274///
275/// Roles, permission overwrites and the `@everyone` id are all guild-local, so
276/// the role-set comparison in [`check_visibility`] cannot judge a channel in
277/// another guild — and the bot may not even be a member of that guild. Such
278/// links are refused outright rather than judged.
279fn is_cross_guild(link: GuildId, source: GuildId) -> bool {
280    link != source
281}
282
283/// Returns `true` when the link's visibility must be validated against the
284/// request's source channel.
285///
286/// A link pointing back into the same channel the request came from is always
287/// safe to expand: the reply lands in that very channel, so it cannot expose
288/// anything its readers cannot already see. Such links need no visibility
289/// checks, while links to any other channel do.
290fn requires_visibility_check(target: ChannelId, source: ChannelId) -> bool {
291    target != source
292}
293
294/// Computes the set of roles that can effectively `VIEW_CHANNEL` a channel.
295///
296/// `@everyone` (role id == guild id) is treated as a normal role and included in
297/// the result when applicable. For each role the effective permission is
298/// `@everyone perms | role perms`; a role with `ADMINISTRATOR` always views the
299/// channel. Otherwise the channel's `@everyone` overwrite is applied first, then
300/// the role's own overwrite, each as deny-then-allow.
301fn 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/// Resolves the channel that carries the properties a thread inherits — its
347/// permission overwrites and its NSFW flag.
348///
349/// Threads hold neither of their own, so for any thread the parent channel is
350/// fetched and returned. Non-thread channels are returned unchanged. A thread
351/// without a `parent_id` is treated as an error.
352#[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/// Validates that everyone who can view `source_channel` could also view `channel`.
372///
373/// The expanded content is posted as a single message that all members of
374/// `source_channel` can read, so the linked channel must be at least as visible
375/// as the source channel to avoid leaking restricted content.
376///
377/// Both channels must be in the same guild, which [`Preview::get`] guarantees by
378/// refusing cross-guild links: the role data this compares them against is
379/// guild-local and would be meaningless otherwise.
380///
381/// Comparison is by role set, which cannot express per-member grants, so those
382/// are handled by separate conservative guards: [`has_member_view_deny`] on the
383/// target and [`member_granted_source_is_safe`] on the source.
384#[cfg_attr(coverage_nightly, coverage(off))]
385async fn check_visibility(
386    channel: &GuildChannel,
387    source_channel: &GuildChannel,
388    ctx: &Context,
389) -> Result<(), PreviewError> {
390    // Private threads cannot be represented by the role-set comparison
391    // (membership is per-user), and DMs are outside the guild context, so
392    // both are rejected. Public/news threads fall through and are judged via
393    // their parent channel.
394    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    // Threads follow their parent channel's permissions, so resolve both the
403    // link target and the request source to the channel that actually
404    // defines visibility before comparing.
405    let (dest_perm, source_perm) = tokio::try_join!(
406        permission_channel(channel, ctx),
407        permission_channel(source_channel, ctx),
408    )?;
409
410    // A per-member deny on the target cannot be represented in the role-set
411    // comparison below, so reject conservatively.
412    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    // Clone the role permission map out of the cache so the non-`Send`
420    // `GuildRef` is dropped immediately — holding it across an `await` would
421    // make the future `!Send` and fail to compile in the event handler.
422    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    /// Retrieves a preview for the given message link.
466    ///
467    /// Every rule deciding whether a link may be expanded lives here. In order,
468    /// the link must point into the same guild as `source_channel`, and the
469    /// linked channel must not be NSFW, must not be a private thread or DM, and
470    /// must be viewable by everyone who can view `source_channel`. The expanded
471    /// content is posted as a single message that all members of
472    /// `source_channel` can read, so the linked channel must be at least as
473    /// visible as the source channel to avoid leaking restricted content. Public
474    /// and news threads are judged by their parent channel, which holds both the
475    /// permission overwrites and the NSFW flag they inherit.
476    ///
477    /// The guild boundary is checked before the channel is resolved. Roles and
478    /// permission overwrites are guild-local, so a link into another guild
479    /// cannot be judged at all, and resolving it would spend rate limit on a
480    /// guild the bot may not even be in.
481    ///
482    /// When the link target is the same channel as `source_channel`, the
483    /// visibility checks are skipped entirely: the reply lands in that same
484    /// channel, so it cannot expose anything its readers cannot already see.
485    #[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            // Not `args.guild_id`: that is the value the URL claims. It equals
506            // the source guild after the check above, so take it from the
507            // resolved channel and keep guild scoping sourced from Discord.
508            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        // Judged on the parent for threads, not on `channel.nsfw` directly:
516        // Discord omits `nsfw` from thread objects because threads inherit it,
517        // and serenity defaults the absent field to `false`, so every thread
518        // under an NSFW channel would otherwise slip past this gate.
519        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        // When the link points to the same channel the request came from, the
526        // expansion is posted back into that very channel. Every member who can
527        // read the reply can already read the original message, so there is
528        // nothing to leak and the visibility checks can be skipped. This
529        // notably covers quoting within a private channel, which would otherwise
530        // be rejected by the per-member deny guard.
531        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        // Non-discord domain should not match (regex anchors to discord.com)
615        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    // --- Privacy / permission resolution ---
636
637    use serenity::all::UserId;
638
639    /// Builds a role VIEW_CHANNEL overwrite.
640    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    /// Builds a per-member overwrite allowing and/or denying VIEW_CHANNEL.
657    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        // role deny is not a member deny
690        assert!(!has_member_view_deny(&[role_ow(1, false, true)]));
691        // member allow (not deny) does not trigger
692        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        // A role allow is not a per-member grant.
700        assert!(!has_member_view_allow(&[role_ow(1, true, false)]));
701        // A member deny is not a grant.
702        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        // Whoever was added individually can read anything `@everyone` can.
713        assert!(member_granted_source_is_safe(
714            &granted,
715            &public_target,
716            EVERYONE
717        ));
718        // Their access to a restricted target cannot be established from roles.
719        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        // A source described entirely by roles imposes no extra restriction here,
730        // whatever the target looks like.
731        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        // A member deny on the source narrows it, which is already conservative.
742        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        // Quoting within the same channel needs no visibility check.
753        assert!(!requires_visibility_check(chan, chan));
754        // A link to a different channel still requires validation.
755        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        // A link into the guild it was posted in may be judged further.
762        assert!(!is_cross_guild(guild, guild));
763        // A link from any other guild is refused outright.
764        assert!(is_cross_guild(GuildId::new(8), guild));
765    }
766
767    #[test]
768    fn only_visibility_rejections_count_as_policy() {
769        // Expected outcomes of the visibility policy: logged at debug.
770        assert!(PreviewError::CrossGuild.is_policy_rejection());
771        assert!(PreviewError::Nsfw.is_policy_rejection());
772        assert!(PreviewError::Permission.is_policy_rejection());
773        // Genuine failures: logged at error.
774        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        // @everyone has VIEW_CHANNEL by base permission, no overwrites.
784        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        // source == dest -> subset holds
792        assert!(viewing.is_subset(&viewing));
793    }
794
795    #[test]
796    fn role_gate_allows_matching_member_role() {
797        // @everyone has no base view; member role is granted via overwrite.
798        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        // Both source and dest gated identically -> subset holds (expansion allowed).
812        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        // Source: role-gated, visible to MEMBER.
824        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        // Dest: visible only to SPECIAL.
831        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        // MEMBER can see source but not dest -> leak -> not a subset.
840        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        // Even with @everyone denied, an ADMINISTRATOR role still views.
850        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        // Only SPECIAL is denied; MEMBER should be unaffected.
864        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        // @everyone absent from the role map -> base falls back to empty,
873        // so a role with no view permission and no overwrite cannot view.
874        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        // The same role gains access once an overwrite allows it.
881        let ow = [role_ow(MEMBER.get(), true, false)];
882        let viewing = viewing_roles(&ow, &roles, EVERYONE);
883        assert!(viewing.contains(&MEMBER));
884    }
885
886    // --- Preview embed rendering ---
887
888    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}