Checkpoint

This commit is contained in:
2025-08-12 11:24:09 -03:00
parent 84e3b533f0
commit cd7f4c1cc9
16 changed files with 609 additions and 91 deletions
+333
View File
@@ -0,0 +1,333 @@
//! Automated moderation for call messages.
//!
//! # Rules
//!
//! Payphone censors, allows, or blocks messages based on a predefined set of rule categories:
//!
//! | Nº | Message Type | Examples | Action |
//! |:--:|-----------------------------------------|-------------------------------------------------------|-----------------------------|
//! | 1 | Hate speech and slurs | "nigga", "faggot", "retard", "dumbfuck", racial slurs | Block immediately + penalty |
//! | 2 | Threats, self harm | "kill yourself", "die" | Block immediately + penalty |
//! | 3 | Targeted harassment | "kill @user", "die @user" | Block immediately + penalty |
//! | 4 | Explicit sexual language | "fuck you", "dick", "pussy" | Block immediately + penalty |
//! | 5 | Suggestive language | "slap your ass", "sexy" | Block immediately + penalty |
//! | 6 | Advertising | "discord.gg/payphone" | Block immediately + penalty |
//! | 7 | Sensitive topics and triggering content | "blood", "knife", "suicide method" | Block immediately + penalty |
//! | 8 | Non-english chats | | Block immediately + penalty |
//! | 9 | E-dating | "let me hit" | Block immediately + penalty |
//! | 10 | Mild swear words | "damn", "shit", "ass" | Soft censor + warn |
//! | 11 | Casual insults and teasing | "stupid", "dumb" | Soft censor + warn |
//! | 12 | Links | "https://google.com/" | Soft censor + warn |
//! | 13 | Spam and flooding | | Soft censor + warn |
//! | 14 | Normal conversations | "hey", "lol", "cool" | Allowed freely |
use std::{collections::HashMap, sync::Arc};
use linkify::LinkFinder;
use once_cell::sync::Lazy;
use regex::Regex;
use twilight_model::gateway::payload::incoming::MessageCreate;
use unicode_segmentation::UnicodeSegmentation;
use unidecode::unidecode;
use crate::discord::sanitizing::SanitizeDiscordMarkdown;
pub fn escape_message(event: &MessageCreate) -> String {
let mut message = event.content.to_string();
for mention in &event.mentions {
message = message.replace(
&format!("<@{}>", mention.id),
&format!("**@{}**", mention.name.sanitize(true)),
);
}
for mention in &event.mention_roles {
message = message.replace(&format!("<@&{mention}>"), "**@Role**");
}
for mention in &event.mention_channels {
message = message.replace(
&format!("<#{}>", mention.id),
&format!("**#{}**", mention.name.sanitize(true)),
);
}
let mut finder = LinkFinder::new();
finder.url_must_have_scheme(false);
for link in finder.links(&message.clone()) {
let url = link.as_str();
message = message.replace(url, &"\\#".repeat(url.len()));
}
let normalized = normalize(message.clone());
log::debug!("Escaped and normalized message: {normalized:?}");
message.sanitize(false)
}
#[derive(Debug)]
struct NormalizedMessage {
/// The normalized message content.
message: String,
/// Mappings from original indices to normalized indices, or None if it was removed.
mappings: HashMap<(usize, usize), Option<(usize, usize)>>,
}
pub fn normalize(message: String) -> NormalizedMessage {
let replacements: HashMap<char, char> = [
('@', 'a'),
('0', 'o'),
('1', 'i'),
('2', 'z'),
('3', 'e'),
('4', 'a'),
('5', 's'),
('6', 'b'),
('7', 't'),
('8', 'g'),
('9', 'q'),
]
.iter()
.cloned()
.collect();
let mut normalized = String::with_capacity(message.len());
let mut mappings = HashMap::new();
let mut last_i = 0;
for (i, mut ch) in message
.grapheme_indices(true)
.map(|(i, ch)| (i, ch.to_string()))
{
if let Some(&replacement) = replacements.get(&ch.chars().next().unwrap()) {
ch = replacement.to_string();
}
let unidecoded = unidecode(&ch);
let ui_start = normalized.len();
let ui_end = ui_start + unidecoded.len();
if unidecoded.chars().all(|c| c.is_ascii_alphanumeric()) {
// If the character is alphanumeric, we can keep it.
normalized.push_str(&unidecoded);
mappings.insert((last_i, i), Some((ui_start, ui_end)));
} else {
mappings.insert((last_i, i), None);
}
last_i = i;
}
NormalizedMessage {
message: normalized,
mappings,
}
}
fn regex_from_phrase(phrase: &str) -> String {
let mut pattern = String::new();
for ch in phrase.replace(" ", "").chars() {
pattern.push_str(&format!("{ch}+\\s*"));
}
let pattern = format!("\\b{pattern}\\b");
pattern
}
fn regex_from_phrases_list(phrases: &[&str]) -> Regex {
let mut pattern = String::new();
for phrase in phrases {
if !pattern.is_empty() {
pattern.push('|');
}
pattern.push_str(&format!("({})", regex_from_phrase(phrase)));
}
Regex::new(&pattern).expect("Failed to compile regex")
}
pub fn analyze_message(message: &str) -> Vec<(Vec<String>, Arc<dyn Rule>)> {
let mut results = Vec::new();
for rule in RULES.iter() {
let detections = rule.detect(message);
if !detections.is_empty() {
results.push((detections, rule.clone()));
}
}
results
}
pub fn censor_message(original: &str, normalized: NormalizedMessage, analysis: &[(Vec<String>, Arc<dyn Rule>)]) -> String {
let mut censored = String::with_capacity(original.len());
todo!();
censored
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum Action {
BlockAndPenalty,
SoftCensorAndWarn,
}
pub trait Rule: Send + Sync {
fn name(&self) -> &'static str;
fn action(&self) -> &'static Action;
fn detect(&self, message: &str) -> Vec<String>;
}
fn parse_phrases_file(file_content: &str) -> Vec<&str> {
file_content
.lines()
.filter_map(|line| {
let trimmed = line.trim();
if !trimmed.is_empty() && !trimmed.starts_with("//") {
Some(trimmed)
} else {
None
}
})
.collect()
}
struct HateSpeechAndSlursRule(Regex);
impl HateSpeechAndSlursRule {
fn new() -> Self {
let phrases: Vec<&str> =
parse_phrases_file(include_str!("lists/hate-speech-and-slurs.txt"));
let regex = regex_from_phrases_list(&phrases);
Self(regex)
}
}
impl Rule for HateSpeechAndSlursRule {
fn name(&self) -> &'static str {
"Hate Speech and Slurs"
}
fn action(&self) -> &'static Action {
&Action::BlockAndPenalty
}
fn detect(&self, message: &str) -> Vec<String> {
self.0
.find_iter(message)
.map(|m| message[m.start()..m.end()].to_string())
.collect()
}
}
struct ThreatsAndSelfHarmRule(Regex);
impl ThreatsAndSelfHarmRule {
fn new() -> Self {
let phrases: Vec<&str> =
parse_phrases_file(include_str!("lists/threats-and-self-harm.txt"));
let regex = regex_from_phrases_list(&phrases);
Self(regex)
}
}
impl Rule for ThreatsAndSelfHarmRule {
fn name(&self) -> &'static str {
"Threats and Self Harm"
}
fn action(&self) -> &'static Action {
&Action::BlockAndPenalty
}
fn detect(&self, message: &str) -> Vec<String> {
self.0
.find_iter(message)
.map(|m| message[m.start()..m.end()].to_string())
.collect()
}
}
struct ExplicitSexualLanguageRule(Regex);
impl ExplicitSexualLanguageRule {
fn new() -> Self {
let phrases: Vec<&str> =
parse_phrases_file(include_str!("lists/explicit-sexual-language.txt"));
let regex = regex_from_phrases_list(&phrases);
Self(regex)
}
}
impl Rule for ExplicitSexualLanguageRule {
fn action(&self) -> &'static Action {
&Action::BlockAndPenalty
}
fn name(&self) -> &'static str {
"Explicit Sexual Language"
}
fn detect(&self, message: &str) -> Vec<String> {
self.0
.find_iter(message)
.map(|m| message[m.start()..m.end()].to_string())
.collect()
}
}
struct SuggestiveLanguageRule(Regex);
impl SuggestiveLanguageRule {
fn new() -> Self {
let phrases: Vec<&str> = parse_phrases_file(include_str!("lists/suggestive-language.txt"));
let regex = regex_from_phrases_list(&phrases);
Self(regex)
}
}
impl Rule for SuggestiveLanguageRule {
fn action(&self) -> &'static Action {
&Action::BlockAndPenalty
}
fn name(&self) -> &'static str {
"Suggestive Language"
}
fn detect(&self, message: &str) -> Vec<String> {
self.0
.find_iter(message)
.map(|m| message[m.start()..m.end()].to_string())
.collect()
}
}
pub static RULES: Lazy<Vec<Arc<dyn Rule>>> = Lazy::new(|| {
vec![
Arc::new(HateSpeechAndSlursRule::new()),
Arc::new(ThreatsAndSelfHarmRule::new()),
Arc::new(SuggestiveLanguageRule::new()),
Arc::new(ExplicitSexualLanguageRule::new()),
]
});