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