Skip to main content

babyrite/
expand.rs

1//! Link expansion module.
2//!
3//! This module provides common types for expanding various types of links
4//! (Discord message links, GitHub permalinks, etc.) into rich preview content.
5
6pub mod discord;
7pub mod github;
8
9use crate::config::BabyriteConfig;
10use regex::Regex;
11use serenity::all::{Context, CreateEmbed, GuildId, Message};
12use std::collections::HashSet;
13
14/// Shared inputs for expanding the links of one message.
15pub struct ExpandContext<'a> {
16    /// The serenity context.
17    pub ctx: &'a Context,
18    /// The message whose links are being expanded.
19    pub message: &'a Message,
20    /// The guild the message was sent in.
21    pub guild_id: GuildId,
22}
23
24/// A link expander: parses its own link type out of a message and expands
25/// each link into [`ExpandedContent`].
26///
27/// Adding a new link type means implementing this trait and registering the
28/// expander in [`EXPANDERS`]; the event handler needs no changes.
29#[serenity::async_trait]
30pub trait LinkExpander: Send + Sync {
31    /// Whether this expander is enabled under `config`.
32    fn enabled(&self, config: &BabyriteConfig) -> bool;
33
34    /// Parses and expands all links of this expander's type in the message.
35    ///
36    /// Failures are logged per link and never abort the other links or
37    /// expanders, so this returns only the successful expansions. Links refused
38    /// on purpose — because expanding them would leak content the audience
39    /// cannot see — are logged at `debug` rather than `error`.
40    async fn expand_all(&self, cx: &ExpandContext<'_>) -> Vec<ExpandedContent>;
41}
42
43/// All registered link expanders, in the order their results appear in a reply.
44pub static EXPANDERS: &[&dyn LinkExpander] = &[&discord::DiscordExpander, &github::GitHubExpander];
45
46/// Maximum number of links expanded per message.
47const MAX_LINKS_PER_MESSAGE: usize = 3;
48
49/// Extracts links matching `regex` from `text`, applying the shared link policy:
50/// URLs wrapped in angle brackets (e.g. `<https://...>`) are skipped, duplicate
51/// URLs are ignored, and at most [`MAX_LINKS_PER_MESSAGE`] links are returned.
52///
53/// `parse` converts a regex match into the expander-specific link type;
54/// returning `None` drops that match.
55pub(crate) fn parse_links<T>(
56    text: &str,
57    regex: &Regex,
58    parse: impl Fn(&regex::Captures) -> Option<T>,
59) -> Vec<T> {
60    let mut seen_urls = HashSet::new();
61    regex
62        .captures_iter(text)
63        .filter_map(|captures| {
64            let m = captures.get(0)?;
65            if m.start() > 0 && text.as_bytes()[m.start() - 1] == b'<' {
66                return None;
67            }
68            if !seen_urls.insert(m.as_str()) {
69                return None;
70            }
71            parse(&captures)
72        })
73        .take(MAX_LINKS_PER_MESSAGE)
74        .collect()
75}
76
77/// Expanded content produced by a link expander.
78///
79/// Represents the different kinds of content that can result from
80/// expanding a link.
81pub enum ExpandedContent {
82    /// A Discord message preview displayed as an embed.
83    ///
84    /// Boxed because an embed dwarfs the other variant.
85    Embed(Box<CreateEmbed>),
86    /// A code block with syntax highlighting (e.g. GitHub permalink).
87    CodeBlock {
88        /// The programming language for syntax highlighting.
89        language: String,
90        /// The code content.
91        code: String,
92        /// Metadata line displayed above the code block (e.g. file path, line range).
93        metadata: String,
94    },
95}
96
97/// Errors that can occur during link expansion.
98#[derive(thiserror::Error, Debug)]
99pub enum ExpandError {
100    /// An error from the Discord message link expander.
101    #[error(transparent)]
102    Discord(#[from] discord::PreviewError),
103    /// An error from the GitHub permalink expander.
104    #[error(transparent)]
105    GitHub(#[from] github::GitHubExpandError),
106}