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#[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 #[must_use]
29 pub fn clean_role(mut self, b: bool) -> Self {
30 self.clean_role = b;
31
32 self
33 }
34
35 #[must_use]
39 pub fn clean_user(mut self, b: bool) -> Self {
40 self.clean_user = b;
41
42 self
43 }
44
45 #[must_use]
49 pub fn clean_channel(mut self, b: bool) -> Self {
50 self.clean_channel = b;
51
52 self
53 }
54
55 #[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 #[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 #[must_use]
79 pub fn clean_here(mut self, b: bool) -> Self {
80 self.clean_here = b;
81
82 self
83 }
84
85 #[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 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
109pub 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 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 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 let mut cleaned = false;
197 if should_parse {
198 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)] 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 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 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 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 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 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}