veloren_server/
automod.rs

1use crate::settings::ModerationSettings;
2use authc::Uuid;
3use censor::Censor;
4use common::comp::{AdminRole, ChatMsg, ChatType, Group};
5use hashbrown::HashMap;
6use std::{
7    fmt,
8    sync::Arc,
9    time::{Duration, Instant},
10};
11use tracing::info;
12
13pub enum ActionNote {
14    SpamWarn,
15}
16
17impl fmt::Display for ActionNote {
18    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
19        match self {
20            ActionNote::SpamWarn => write!(
21                f,
22                "You've sent a lot of messages recently. Make sure to reduce the rate of messages \
23                 or you will be automatically muted."
24            ),
25        }
26    }
27}
28
29pub enum ActionErr {
30    BannedWord,
31    TooLong,
32    SpamMuted(Duration),
33}
34
35impl fmt::Display for ActionErr {
36    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
37        match self {
38            ActionErr::BannedWord => write!(
39                f,
40                "Your message contained a banned word. If you think this is a mistake, please let \
41                 a moderator know."
42            ),
43            ActionErr::TooLong => write!(
44                f,
45                "Your message was too long, no more than {} characters are permitted.",
46                ChatMsg::MAX_BYTES_PLAYER_CHAT_MSG
47            ),
48            ActionErr::SpamMuted(dur) => write!(
49                f,
50                "You have sent too many messages and are muted for {} seconds.",
51                dur.as_secs_f32() as u64
52            ),
53        }
54    }
55}
56
57pub struct AutoMod {
58    settings: ModerationSettings,
59    censor: Arc<Censor>,
60    players: HashMap<Uuid, PlayerState>,
61}
62
63impl AutoMod {
64    pub fn new(settings: &ModerationSettings, censor: Arc<Censor>) -> Self {
65        if settings.automod {
66            info!(
67                "Automod enabled, players{} will be subject to automated spam/content filters",
68                if settings.admins_exempt {
69                    ""
70                } else {
71                    " (and admins)"
72                }
73            );
74        } else {
75            info!("Automod disabled");
76        }
77
78        Self {
79            settings: settings.clone(),
80            censor,
81            players: HashMap::default(),
82        }
83    }
84
85    pub fn enabled(&self) -> bool { self.settings.automod }
86
87    fn player_mut(&mut self, player: Uuid) -> &mut PlayerState {
88        self.players.entry(player).or_default()
89    }
90
91    pub fn validate_chat_msg(
92        &mut self,
93        player: Uuid,
94        role: Option<AdminRole>,
95        now: Instant,
96        chat_type: &ChatType<Group>,
97        msg: &str,
98    ) -> Result<Option<ActionNote>, ActionErr> {
99        // TODO: Consider using grapheme cluster count instead of size in bytes
100        if msg.len() > ChatMsg::MAX_BYTES_PLAYER_CHAT_MSG {
101            Err(ActionErr::TooLong)
102        } else if !self.settings.automod
103            // Is this a private chat message?
104            || chat_type.is_private().unwrap_or(true)
105            // Is the user exempt from automoderation?
106            || (role.is_some() && self.settings.admins_exempt)
107        {
108            Ok(None)
109        } else if self.censor.check(msg) {
110            Err(ActionErr::BannedWord)
111        } else {
112            let volume = self.player_mut(player).enforce_message_volume(now);
113
114            if let Some(until) = self.player_mut(player).muted_until {
115                Err(ActionErr::SpamMuted(until.saturating_duration_since(now)))
116            } else if volume > 0.75 {
117                Ok(Some(ActionNote::SpamWarn))
118            } else {
119                Ok(None)
120            }
121        }
122    }
123}
124
125/// The period, in seconds, over which chat volume should be tracked to detect
126/// spam.
127const CHAT_VOLUME_PERIOD: f32 = 30.0;
128/// The maximum permitted average number of chat messages over the chat volume
129/// period.
130const MAX_AVG_MSG_PER_SECOND: f32 = 1.0 / 5.0; // No more than a message every 5 seconds on average
131/// The period for which a player should be muted when they exceed the message
132/// spam threshold.
133const SPAM_MUTE_PERIOD: Duration = Duration::from_secs(180);
134
135#[derive(Default)]
136pub struct PlayerState {
137    last_msg_time: Option<Instant>,
138    /// The average number of messages per second over the last N seconds.
139    chat_volume: f32,
140    muted_until: Option<Instant>,
141}
142
143impl PlayerState {
144    // 0.0 => message is permitted, nothing unusual
145    // >=1.0 => message is not permitted, chat volume exceeded
146    pub fn enforce_message_volume(&mut self, now: Instant) -> f32 {
147        if self.muted_until.is_some_and(|u| u <= now) {
148            self.muted_until = None;
149        }
150
151        if let Some(time_since_last) = self
152            .last_msg_time
153            .map(|last| now.saturating_duration_since(last).as_secs_f32())
154        {
155            let time_proportion = (time_since_last / CHAT_VOLUME_PERIOD).min(1.0);
156            self.chat_volume = self.chat_volume * (1.0 - time_proportion)
157                + (1.0 / time_since_last) * time_proportion;
158        } else {
159            self.chat_volume = 0.0;
160        }
161        self.last_msg_time = Some(now);
162
163        let min_level = 1.0 / CHAT_VOLUME_PERIOD;
164        let max_level = MAX_AVG_MSG_PER_SECOND;
165
166        let volume = ((self.chat_volume - min_level) / (max_level - min_level)).max(0.0);
167
168        if volume > 1.0 && self.muted_until.is_none() {
169            self.muted_until = now.checked_add(SPAM_MUTE_PERIOD);
170        }
171
172        volume
173    }
174}