Skip to main content

babyrite/
reply.rs

1//! Assembly of the messages Babyrite posts back to the requester.
2//!
3//! Everything the bot says goes through [`build_messages`], so the reply
4//! policy — what is allowed to be mentioned, and what rides in which message —
5//! is decided in one place instead of at each call site.
6
7use crate::expand::ExpandedContent;
8use crate::utils::defuse_code_fences;
9use serenity::all::{CreateAllowedMentions, CreateMessage, Message};
10
11/// Builds the messages answering `request`, in the order they are sent.
12///
13/// Embeds ride in a single reply, while each code block gets a message of its
14/// own: a block fills the message content, and a message carries only one.
15pub fn build_messages(request: &Message, results: Vec<ExpandedContent>) -> Vec<CreateMessage> {
16    let mut embeds = Vec::new();
17    let mut code_blocks = Vec::new();
18
19    for result in results {
20        match result {
21            ExpandedContent::Embed(embed) => embeds.push(*embed),
22            ExpandedContent::CodeBlock {
23                language,
24                code,
25                metadata,
26            } => {
27                let code = defuse_code_fences(&code);
28                // `allowed_mentions` is set explicitly, and empty: left unset,
29                // Discord parses every mention in the content under the bot's
30                // permissions. The content is fetched from a repository the
31                // requester chose, so that would let it borrow mention rights
32                // the requester may not hold.
33                code_blocks.push(
34                    CreateMessage::new()
35                        .content(format!("{metadata}\n```{language}\n{code}\n```"))
36                        .allowed_mentions(CreateAllowedMentions::new()),
37                );
38            }
39        }
40    }
41
42    if embeds.is_empty() {
43        return code_blocks;
44    }
45
46    // Only the reply ping is allowed: the quoted message is someone else's
47    // content and must not gain mention rights by being echoed by the bot.
48    let preview = CreateMessage::new()
49        .embeds(embeds)
50        .reference_message(request)
51        .allowed_mentions(CreateAllowedMentions::new().replied_user(true));
52
53    std::iter::once(preview).chain(code_blocks).collect()
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59    use serenity::all::CreateEmbed;
60
61    /// `CreateMessage` has neither getters nor `PartialEq`, so what it would
62    /// send is inspected through `Debug` rather than adding a JSON dependency
63    /// used by nothing but these tests.
64    fn rendered(message: &CreateMessage) -> String {
65        format!("{message:?}")
66    }
67
68    fn embed() -> ExpandedContent {
69        ExpandedContent::Embed(Box::new(CreateEmbed::new().description("quoted")))
70    }
71
72    fn code_block() -> ExpandedContent {
73        ExpandedContent::CodeBlock {
74            language: "rust".to_string(),
75            code: "fn main() {}".to_string(),
76            metadata: "src/main.rs L1".to_string(),
77        }
78    }
79
80    #[test]
81    fn embeds_share_one_message_and_every_code_block_gets_its_own() {
82        let messages = build_messages(
83            &Message::default(),
84            vec![embed(), code_block(), embed(), code_block()],
85        );
86
87        assert_eq!(messages.len(), 3);
88    }
89
90    #[test]
91    fn embeds_are_sent_before_code_blocks() {
92        let messages = build_messages(&Message::default(), vec![code_block(), embed()]);
93
94        assert!(rendered(&messages[0]).contains("quoted"));
95        assert!(rendered(&messages[1]).contains("src/main.rs L1"));
96    }
97
98    #[test]
99    fn nothing_is_sent_without_expanded_content() {
100        assert!(build_messages(&Message::default(), Vec::new()).is_empty());
101    }
102
103    #[test]
104    fn code_block_is_fenced_with_its_language_below_its_metadata() {
105        let messages = build_messages(&Message::default(), vec![code_block()]);
106
107        assert!(rendered(&messages[0]).contains(r"src/main.rs L1\n```rust\nfn main() {}\n```"));
108    }
109
110    #[test]
111    fn only_the_reply_ping_is_allowed_in_the_preview() {
112        let messages = build_messages(&Message::default(), vec![embed()]);
113
114        let rendered = rendered(&messages[0]);
115        assert!(rendered.contains("parse: [], users: [], roles: [], replied_user: Some(true)"));
116    }
117
118    #[test]
119    fn a_code_block_mentions_nobody() {
120        let messages = build_messages(&Message::default(), vec![code_block()]);
121
122        let rendered = rendered(&messages[0]);
123        assert!(rendered.contains("parse: [], users: [], roles: [], replied_user: None"));
124    }
125}