Skip to main content

serenity/utils/
content_safe.rs

1use std::borrow::Cow;
2
3use crate::cache::Cache;
4use crate::model::id::GuildId;
5use crate::model::mention::Mention;
6use crate::model::user::User;
7
8/// Struct that allows to alter [`content_safe`]'s behaviour.
9#[derive(Clone, Debug)]
10pub struct ContentSafeOptions {
11    clean_role: bool,
12    clean_user: bool,
13    clean_channel: bool,
14    clean_here: bool,
15    clean_everyone: bool,
16    show_discriminator: bool,
17    guild_reference: Option<GuildId>,
18}
19
20impl ContentSafeOptions {
21    #[must_use]
22    pub fn new() -> Self {
23        ContentSafeOptions::default()
24    }
25
26    /// [`content_safe`] will replace role mentions (`<@&{id}>`) with its name prefixed with `@`
27    /// (`@rolename`) or with `@deleted-role` if the identifier is invalid.
28    #[must_use]
29    pub fn clean_role(mut self, b: bool) -> Self {
30        self.clean_role = b;
31
32        self
33    }
34
35    /// If set to true, [`content_safe`] will replace user mentions (`<@!{id}>` or `<@{id}>`) with
36    /// the user's name prefixed with `@` (`@username`) or with `@invalid-user` if the identifier
37    /// is invalid.
38    #[must_use]
39    pub fn clean_user(mut self, b: bool) -> Self {
40        self.clean_user = b;
41
42        self
43    }
44
45    /// If set to true, [`content_safe`] will replace channel mentions (`<#{id}>`) with the
46    /// channel's name prefixed with `#` (`#channelname`) or with `#deleted-channel` if the
47    /// identifier is invalid.
48    #[must_use]
49    pub fn clean_channel(mut self, b: bool) -> Self {
50        self.clean_channel = b;
51
52        self
53    }
54
55    /// If set to true, if [`content_safe`] replaces a user mention it will add their four digit
56    /// discriminator with a preceding `#`, turning `@username` to `@username#discriminator`.
57    ///
58    /// This option is ignored if the username is a next-gen username, and
59    /// therefore does not have a discriminator.
60    #[must_use]
61    #[deprecated = "Discriminators are deprecated on the discord side, and this doesn't reflect message rendering behaviour"]
62    pub fn show_discriminator(mut self, b: bool) -> Self {
63        self.show_discriminator = b;
64
65        self
66    }
67
68    /// If set, [`content_safe`] will replace a user mention with the user's display name in passed
69    /// `guild`.
70    #[must_use]
71    pub fn display_as_member_from<G: Into<GuildId>>(mut self, guild: G) -> Self {
72        self.guild_reference = Some(guild.into());
73
74        self
75    }
76
77    /// If set, [`content_safe`] will replace `@here` with a non-pinging alternative.
78    #[must_use]
79    pub fn clean_here(mut self, b: bool) -> Self {
80        self.clean_here = b;
81
82        self
83    }
84
85    /// If set, [`content_safe`] will replace `@everyone` with a non-pinging alternative.
86    #[must_use]
87    pub fn clean_everyone(mut self, b: bool) -> Self {
88        self.clean_everyone = b;
89
90        self
91    }
92}
93
94impl Default for ContentSafeOptions {
95    /// Instantiates with all options set to `true`.
96    fn default() -> Self {
97        ContentSafeOptions {
98            clean_role: true,
99            clean_user: true,
100            clean_channel: true,
101            clean_here: true,
102            clean_everyone: true,
103            show_discriminator: true,
104            guild_reference: None,
105        }
106    }
107}
108
109/// Transforms role, channel, user, `@everyone` and `@here` mentions into raw text by using the
110/// [`Cache`] and the users passed in with `users`.
111///
112/// [`ContentSafeOptions`] decides what kind of mentions should be filtered and how the raw-text
113/// will be displayed.
114///
115/// # Examples
116///
117/// Sanitise an `@everyone` mention.
118///
119/// ```rust
120/// # use serenity::client::Cache;
121/// #
122/// # let cache = Cache::default();
123/// use serenity::utils::{content_safe, ContentSafeOptions};
124///
125/// let with_mention = "@everyone";
126/// let without_mention = content_safe(&cache, &with_mention, &ContentSafeOptions::default(), &[]);
127///
128/// assert_eq!("@\u{200B}everyone".to_string(), without_mention);
129/// ```
130///
131/// Filtering out mentions from a message.
132///
133/// ```rust
134/// use serenity::client::Cache;
135/// use serenity::model::channel::Message;
136/// use serenity::utils::{content_safe, ContentSafeOptions};
137///
138/// fn filter_message(cache: &Cache, message: &Message) -> String {
139///     content_safe(cache, &message.content, &ContentSafeOptions::default(), &message.mentions)
140/// }
141/// ```
142pub fn content_safe(
143    cache: impl AsRef<Cache>,
144    s: impl AsRef<str>,
145    options: &ContentSafeOptions,
146    users: &[User],
147) -> String {
148    let mut content = clean_mentions(&cache, s, options, users);
149
150    if options.clean_here {
151        content = content.replace("@here", "@\u{200B}here");
152    }
153
154    if options.clean_everyone {
155        content = content.replace("@everyone", "@\u{200B}everyone");
156    }
157
158    content
159}
160
161fn clean_mentions(
162    cache: impl AsRef<Cache>,
163    s: impl AsRef<str>,
164    options: &ContentSafeOptions,
165    users: &[User],
166) -> String {
167    let s = s.as_ref();
168    let mut content = String::with_capacity(s.len());
169    let mut brackets = s.match_indices(['<', '>']).peekable();
170    let mut progress = 0;
171    while let Some((idx1, b1)) = brackets.next() {
172        // Find inner-most pairs of angle brackets
173        if b1 == "<" {
174            if let Some(&(idx2, b2)) = brackets.peek() {
175                if b2 == ">" {
176                    content.push_str(&s[progress..idx1]);
177                    let mention_str = &s[idx1..=idx2];
178
179                    // Don't waste time parsing if we're not going to clean the mention anyway
180                    // NOTE: Emoji mentions aren't cleaned.
181                    let mut chars = mention_str.chars();
182                    chars.next();
183                    let should_parse = match chars.next() {
184                        Some('#') => options.clean_channel,
185                        Some('@') => {
186                            if let Some('&') = chars.next() {
187                                options.clean_role
188                            } else {
189                                options.clean_user
190                            }
191                        },
192                        _ => false,
193                    };
194
195                    // I wish let_chains were stabilized :(
196                    let mut cleaned = false;
197                    if should_parse {
198                        // NOTE: numeric strings that are too large to fit into u64 will not parse
199                        // correctly and will be left unchanged.
200                        if let Ok(mention) = mention_str.parse() {
201                            content.push_str(&clean_mention(&cache, mention, options, users));
202                            cleaned = true;
203                        }
204                    }
205                    if !cleaned {
206                        content.push_str(mention_str);
207                    }
208                    progress = idx2 + 1;
209                }
210            }
211        }
212    }
213    content.push_str(&s[progress..]);
214    content
215}
216
217fn clean_mention(
218    cache: impl AsRef<Cache>,
219    mention: Mention,
220    options: &ContentSafeOptions,
221    users: &[User],
222) -> Cow<'static, str> {
223    let cache = cache.as_ref();
224    match mention {
225        Mention::Channel(id) => {
226            #[allow(deprecated)] // This is reworked on next already.
227            if let Some(channel) = id.to_channel_cached(cache) {
228                format!("#{}", channel.name).into()
229            } else {
230                "#deleted-channel".into()
231            }
232        },
233        Mention::Role(id) => options
234            .guild_reference
235            .and_then(|id| cache.guild(id))
236            .and_then(|g| g.roles.get(&id).map(|role| format!("@{}", role.name).into()))
237            .unwrap_or(Cow::Borrowed("@deleted-role")),
238        Mention::User(id) => {
239            if let Some(guild_id) = options.guild_reference {
240                if let Some(guild) = cache.guild(guild_id) {
241                    if let Some(member) = guild.members.get(&id) {
242                        return if options.show_discriminator {
243                            #[allow(deprecated)]
244                            let name = member.distinct();
245                            format!("@{name}")
246                        } else {
247                            format!("@{}", member.display_name())
248                        }
249                        .into();
250                    }
251                }
252            }
253
254            let get_username = |user: &User| {
255                if options.show_discriminator {
256                    format!("@{}", user.tag())
257                } else {
258                    format!("@{}", user.name)
259                }
260                .into()
261            };
262
263            cache
264                .user(id)
265                .map(|u| get_username(&u))
266                .or_else(|| users.iter().find(|u| u.id == id).map(get_username))
267                .unwrap_or(Cow::Borrowed("@invalid-user"))
268        },
269    }
270}
271
272#[allow(clippy::non_ascii_literal)]
273#[cfg(test)]
274mod tests {
275    use std::sync::Arc;
276
277    use super::*;
278    use crate::model::channel::*;
279    use crate::model::guild::*;
280    use crate::model::id::{ChannelId, RoleId, UserId};
281
282    #[test]
283    fn test_content_safe() {
284        let user = User {
285            id: UserId::new(100000000000000000),
286            name: "Crab".to_string(),
287            ..Default::default()
288        };
289
290        let outside_cache_user = User {
291            id: UserId::new(100000000000000001),
292            name: "Boat".to_string(),
293            ..Default::default()
294        };
295
296        let mut guild = Guild {
297            id: GuildId::new(381880193251409931),
298            ..Default::default()
299        };
300
301        let member = Member {
302            nick: Some("Ferris".to_string()),
303            ..Default::default()
304        };
305
306        let role = Role {
307            id: RoleId::new(333333333333333333),
308            name: "ferris-club-member".to_string(),
309            ..Default::default()
310        };
311
312        let channel = GuildChannel {
313            id: ChannelId::new(111880193700067777),
314            name: "general".to_string(),
315            ..Default::default()
316        };
317
318        let cache = Arc::new(Cache::default());
319
320        guild.channels.insert(channel.id, channel.clone());
321        guild.members.insert(user.id, member.clone());
322        guild.roles.insert(role.id, role);
323        cache.users.insert(user.id, user.clone());
324        cache.guilds.insert(guild.id, guild.clone());
325        cache.channels.insert(channel.id, guild.id);
326
327        let with_user_mentions = "<@!100000000000000000> <@!000000000000000000> <@123> <@!123> \
328        <@!123123123123123123123> <@123> <@123123123123123123> <@!invalid> \
329        <@invalid> <@日本語 한국어$§)[/__#\\(/&2032$§#> \
330        <@!i)/==(<<>z/9080)> <@!1231invalid> <@invalid123> \
331        <@123invalid> <@> <@ ";
332
333        let without_user_mentions = "@Crab <@!000000000000000000> @invalid-user @invalid-user \
334        <@!123123123123123123123> @invalid-user @invalid-user <@!invalid> \
335        <@invalid> <@日本語 한국어$§)[/__#\\(/&2032$§#> \
336        <@!i)/==(<<>z/9080)> <@!1231invalid> <@invalid123> \
337        <@123invalid> <@> <@ ";
338
339        // User mentions
340        let options = ContentSafeOptions::default();
341        assert_eq!(without_user_mentions, content_safe(&cache, with_user_mentions, &options, &[]));
342
343        let options = ContentSafeOptions::default();
344        assert_eq!(
345            format!("@{}", user.name),
346            content_safe(&cache, "<@!100000000000000000>", &options, &[])
347        );
348
349        let options = ContentSafeOptions::default();
350        assert_eq!(
351            format!("@{}", user.name),
352            content_safe(&cache, "<@100000000000000000>", &options, &[])
353        );
354
355        let options = ContentSafeOptions::default();
356        assert_eq!("@invalid-user", content_safe(&cache, "<@100000000000000001>", &options, &[]));
357
358        let options = ContentSafeOptions::default();
359        assert_eq!(
360            format!("@{}", outside_cache_user.name),
361            content_safe(&cache, "<@100000000000000001>", &options, &[outside_cache_user])
362        );
363
364        #[allow(deprecated)]
365        let options = options.show_discriminator(false);
366        assert_eq!(
367            format!("@{}", user.name),
368            content_safe(&cache, "<@!100000000000000000>", &options, &[])
369        );
370
371        #[allow(deprecated)]
372        let options = options.show_discriminator(false);
373        assert_eq!(
374            format!("@{}", user.name),
375            content_safe(&cache, "<@100000000000000000>", &options, &[])
376        );
377
378        let options = options.display_as_member_from(guild.id);
379        assert_eq!(
380            format!("@{}", member.nick.unwrap()),
381            content_safe(&cache, "<@!100000000000000000>", &options, &[])
382        );
383
384        let options = options.clean_user(false);
385        assert_eq!(with_user_mentions, content_safe(&cache, with_user_mentions, &options, &[]));
386
387        // Channel mentions
388        let with_channel_mentions = "<#> <#deleted-channel> #deleted-channel <#1> \
389        #unsafe-club <#111880193700067777> <#ferrisferrisferris> \
390        <#000000000000000001>";
391
392        let without_channel_mentions = "<#> <#deleted-channel> #deleted-channel \
393        #deleted-channel #unsafe-club #general <#ferrisferrisferris> \
394        #deleted-channel";
395
396        assert_eq!(
397            without_channel_mentions,
398            content_safe(&cache, with_channel_mentions, &options, &[])
399        );
400
401        let options = options.clean_channel(false);
402        assert_eq!(
403            with_channel_mentions,
404            content_safe(&cache, with_channel_mentions, &options, &[])
405        );
406
407        // Role mentions
408        let with_role_mentions = "<@&> @deleted-role <@&9829> \
409        <@&333333333333333333> <@&000000000000000001> \
410        <@&111111111111111111111111111111> <@&<@&1234>";
411
412        let without_role_mentions = "<@&> @deleted-role @deleted-role \
413        @ferris-club-member @deleted-role \
414        <@&111111111111111111111111111111> <@&@deleted-role";
415
416        assert_eq!(without_role_mentions, content_safe(&cache, with_role_mentions, &options, &[]));
417
418        let options = options.clean_role(false);
419        assert_eq!(with_role_mentions, content_safe(&cache, with_role_mentions, &options, &[]));
420
421        // Everyone mentions
422        let with_everyone_mention = "@everyone";
423
424        let without_everyone_mention = "@\u{200B}everyone";
425
426        assert_eq!(
427            without_everyone_mention,
428            content_safe(&cache, with_everyone_mention, &options, &[])
429        );
430
431        let options = options.clean_everyone(false);
432        assert_eq!(
433            with_everyone_mention,
434            content_safe(&cache, with_everyone_mention, &options, &[])
435        );
436
437        // Here mentions
438        let with_here_mention = "@here";
439
440        let without_here_mention = "@\u{200B}here";
441
442        assert_eq!(without_here_mention, content_safe(&cache, with_here_mention, &options, &[]));
443
444        let options = options.clean_here(false);
445        assert_eq!(with_here_mention, content_safe(&cache, with_here_mention, &options, &[]));
446    }
447}