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(®ex::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}