use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct ContentSafeOptions {
pub clean_everyone: bool,
pub clean_here: bool,
pub clean_user: bool,
pub clean_role: bool,
pub clean_channel: bool,
}
impl Default for ContentSafeOptions {
fn default() -> Self {
Self { clean_everyone: true, clean_here: true, clean_user: true, clean_role: true, clean_channel: true }
}
}
impl ContentSafeOptions {
pub fn none() -> Self {
Self { clean_everyone: false, clean_here: false, clean_user: false, clean_role: false, clean_channel: false }
}
pub fn clean_everyone(mut self, v: bool) -> Self {
self.clean_everyone = v;
self
}
pub fn clean_here(mut self, v: bool) -> Self {
self.clean_here = v;
self
}
pub fn clean_user(mut self, v: bool) -> Self {
self.clean_user = v;
self
}
pub fn clean_role(mut self, v: bool) -> Self {
self.clean_role = v;
self
}
pub fn clean_channel(mut self, v: bool) -> Self {
self.clean_channel = v;
self
}
}
pub fn content_safe(content: &str, opts: &ContentSafeOptions, users: &[(String, String)]) -> String {
let user_map: HashMap<&str, &str> = users.iter().map(|(id, name)| (id.as_str(), name.as_str())).collect();
let mut out = content.to_string();
if opts.clean_everyone {
out = out.replace("@everyone", "@\u{200B}everyone");
}
if opts.clean_here {
out = out.replace("@here", "@\u{200B}here");
}
if opts.clean_user {
out = replace_mentions(&out, r"<@!?(\d+)>", |id| if let Some(name) = user_map.get(id) { format!("@{}", name) } else { "@[unknown]".to_string() });
}
if opts.clean_role {
out = replace_mentions(&out, r"<@&(\d+)>", |_id| "@[role]".to_string());
}
if opts.clean_channel {
out = replace_mentions(&out, r"<#(\d+)>", |_id| "#[channel]".to_string());
}
out
}
fn replace_mentions<F>(text: &str, pattern: &str, replacement_fn: F) -> String
where
F: Fn(&str) -> String,
{
let (prefix, has_optional_bang, suffix) = match pattern {
r"<@!?(\d+)>" => ("<@", true, ">"),
r"<@&(\d+)>" => ("<@&", false, ">"),
r"<#(\d+)>" => ("<#", false, ">"),
_ => return text.to_string(),
};
let bytes = text.as_bytes();
let n = bytes.len();
let mut result = String::with_capacity(text.len());
let mut i = 0;
while i < n {
if text[i..].starts_with(prefix) {
let after_prefix = i + prefix.len();
let digit_start = if has_optional_bang && text[after_prefix..].starts_with('!') { after_prefix + 1 } else { after_prefix };
let mut j = digit_start;
while j < n && bytes[j].is_ascii_digit() {
j += 1;
}
if j > digit_start && text[j..].starts_with(suffix) {
let id = &text[digit_start..j];
result.push_str(&replacement_fn(id));
i = j + suffix.len();
continue;
}
}
if let Some(ch) = text[i..].chars().next() {
result.push(ch);
i += ch.len_utf8();
} else {
result.push_str(&text[i..]);
break;
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cleans_everyone_and_here() {
let opts = ContentSafeOptions::default();
let out = content_safe("hey @everyone and @here!", &opts, &[]);
assert!(!out.contains("@everyone"));
assert!(!out.contains("@here"));
assert!(out.contains("@\u{200B}everyone"));
assert!(out.contains("@\u{200B}here"));
}
#[test]
fn cleans_user_mention_with_lookup() {
let opts = ContentSafeOptions::default();
let users = vec![("123".to_string(), "Alice".to_string())];
let out = content_safe("Hello <@123>!", &opts, &users);
assert_eq!(out, "Hello @Alice!");
}
#[test]
fn cleans_user_nick_mention() {
let opts = ContentSafeOptions::default();
let out = content_safe("Hello <@!456>!", &opts, &[]);
assert_eq!(out, "Hello @[unknown]!");
}
#[test]
fn cleans_role_mention() {
let opts = ContentSafeOptions::default();
let out = content_safe("Hey <@&789>!", &opts, &[]);
assert_eq!(out, "Hey @[role]!");
}
#[test]
fn cleans_channel_mention() {
let opts = ContentSafeOptions::default();
let out = content_safe("See <#111>.", &opts, &[]);
assert_eq!(out, "See #[channel].");
}
#[test]
fn none_opts_leaves_content_unchanged() {
let opts = ContentSafeOptions::none();
let input = "hey @everyone <@123> <@&456> <#789>";
assert_eq!(content_safe(input, &opts, &[]), input);
}
}