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