Added automod

This commit is contained in:
Joshua Barretto 2022-08-07 15:47:23 +01:00
parent 2d2d6b5c64
commit 52bd7b2485
10 changed files with 254 additions and 55 deletions

10
Cargo.lock generated
View File

@ -553,6 +553,15 @@ dependencies = [
"jobserver",
]
[[package]]
name = "censor"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5563d2728feef9a6186acdd148bccbe850dad63c5ba55a3b3355abc9137cb3eb"
dependencies = [
"once_cell",
]
[[package]]
name = "cesu8"
version = "1.1.0"
@ -6711,6 +6720,7 @@ dependencies = [
"atomicwrites",
"authc",
"bincode",
"censor",
"chrono",
"chrono-tz",
"crossbeam-channel",

View File

@ -54,12 +54,11 @@ use common::{
use common_base::{prof_span, span};
use common_net::{
msg::{
self, validate_chat_msg,
self,
world_msg::{EconomyInfo, PoiInfo, SiteId, SiteInfo},
ChatMsgValidationError, ClientGeneral, ClientMsg, ClientRegister, ClientType,
DisconnectReason, InviteAnswer, Notification, PingMsg, PlayerInfo, PlayerListUpdate,
PresenceKind, RegisterError, ServerGeneral, ServerInit, ServerRegisterAnswer,
MAX_BYTES_CHAT_MSG,
ClientGeneral, ClientMsg, ClientRegister, ClientType, DisconnectReason, InviteAnswer,
Notification, PingMsg, PlayerInfo, PlayerListUpdate, PresenceKind, RegisterError,
ServerGeneral, ServerInit, ServerRegisterAnswer,
},
sync::WorldSyncExt,
};
@ -1551,15 +1550,7 @@ impl Client {
pub fn inventories(&self) -> ReadStorage<comp::Inventory> { self.state.read_storage() }
/// Send a chat message to the server.
pub fn send_chat(&mut self, message: String) {
match validate_chat_msg(&message) {
Ok(()) => self.send_msg(ClientGeneral::ChatMsg(message)),
Err(ChatMsgValidationError::TooLong) => warn!(
"Attempted to send a message that's too long (Over {} bytes)",
MAX_BYTES_CHAT_MSG
),
}
}
pub fn send_chat(&mut self, message: String) { self.send_msg(ClientGeneral::ChatMsg(message)); }
/// Send a command to the server.
pub fn send_command(&mut self, name: String, args: Vec<String>) {

View File

@ -41,18 +41,3 @@ pub enum PingMsg {
Ping,
Pong,
}
pub const MAX_BYTES_CHAT_MSG: usize = 256;
pub enum ChatMsgValidationError {
TooLong,
}
pub fn validate_chat_msg(msg: &str) -> Result<(), ChatMsgValidationError> {
// TODO: Consider using grapheme cluster count instead of size in bytes
if msg.len() <= MAX_BYTES_CHAT_MSG {
Ok(())
} else {
Err(ChatMsgValidationError::TooLong)
}
}

View File

@ -58,6 +58,7 @@ slab = "0.4"
rand_distr = "0.4.0"
enumset = "1.0.8"
noise = { version = "0.7", default-features = false }
censor = "0.2"
rusqlite = { version = "0.24.2", features = ["array", "vtab", "bundled", "trace"] }
refinery = { git = "https://gitlab.com/veloren/refinery.git", rev = "8ecf4b4772d791e6c8c0a3f9b66a7530fad1af3e", features = ["rusqlite"] }

132
server/src/automod.rs Normal file
View File

@ -0,0 +1,132 @@
use crate::settings::ModerationSettings;
use authc::Uuid;
use censor::Censor;
use common::comp::AdminRole;
use hashbrown::HashMap;
use std::time::{Duration, Instant};
use tracing::info;
pub const MAX_BYTES_CHAT_MSG: usize = 256;
pub enum ActionNote {
SpamWarn,
}
pub enum ActionErr {
BannedWord,
TooLong,
SpamMuted(Duration),
}
pub struct AutoMod {
settings: ModerationSettings,
censor: Censor,
players: HashMap<Uuid, PlayerState>,
}
impl AutoMod {
pub fn new(settings: &ModerationSettings, banned_words: Vec<String>) -> Self {
if settings.automod {
info!(
"Automod enabled, players{} will be subject to automated spam/content filters",
if settings.admins_exempt {
""
} else {
" (and admins)"
}
);
} else {
info!("Automod disabled");
}
Self {
settings: settings.clone(),
censor: Censor::Custom(banned_words.into_iter().collect()),
players: HashMap::default(),
}
}
pub fn enabled(&self) -> bool { self.settings.automod }
fn player_mut(&mut self, player: Uuid) -> &mut PlayerState {
self.players.entry(player).or_default()
}
pub fn validate_chat_msg(
&mut self,
player: Uuid,
role: Option<AdminRole>,
now: Instant,
msg: &str,
) -> Result<Option<ActionNote>, ActionErr> {
// TODO: Consider using grapheme cluster count instead of size in bytes
if msg.len() > MAX_BYTES_CHAT_MSG {
Err(ActionErr::TooLong)
} else if !self.settings.automod || (role.is_some() && self.settings.admins_exempt) {
Ok(None)
} else if self.censor.check(msg) {
Err(ActionErr::BannedWord)
} else {
let volume = self.player_mut(player).enforce_message_volume(now);
if let Some(until) = self.player_mut(player).muted_until {
Err(ActionErr::SpamMuted(until.saturating_duration_since(now)))
} else if volume > 0.75 {
Ok(Some(ActionNote::SpamWarn))
} else {
Ok(None)
}
}
}
}
/// The period, in seconds, over which chat volume should be tracked to detect
/// spam.
const CHAT_VOLUME_PERIOD: f32 = 30.0;
/// The maximum permitted average number of chat messages over the chat volume
/// period.
const MAX_AVG_MSG_PER_SECOND: f32 = 1.0 / 7.0; // No more than a message every 7 seconds on average
/// The period for which a player should be muted when they exceed the message
/// spam threshold.
const SPAM_MUTE_PERIOD: Duration = Duration::from_secs(180);
#[derive(Default)]
pub struct PlayerState {
last_msg_time: Option<Instant>,
/// The average number of messages per second over the last N seconds.
chat_volume: f32,
muted_until: Option<Instant>,
}
impl PlayerState {
// 0.0 => message is permitted, nothing unusual
// >=1.0 => message is not permitted, chat volume exceeded
pub fn enforce_message_volume(&mut self, now: Instant) -> f32 {
if self.muted_until.map_or(false, |u| u <= now) {
self.muted_until = None;
}
if let Some(time_since_last) = self
.last_msg_time
.map(|last| now.saturating_duration_since(last).as_secs_f32())
{
let time_proportion = (time_since_last / CHAT_VOLUME_PERIOD).min(1.0);
self.chat_volume = self.chat_volume * (1.0 - time_proportion)
+ (1.0 / time_since_last) * time_proportion;
} else {
self.chat_volume = 0.0;
}
self.last_msg_time = Some(now);
let min_level = 1.0 / CHAT_VOLUME_PERIOD;
let max_level = MAX_AVG_MSG_PER_SECOND;
let volume = ((self.chat_volume - min_level) / (max_level - min_level)).max(0.0);
if volume > 1.0 && self.muted_until.is_none() {
self.muted_until = now.checked_add(SPAM_MUTE_PERIOD);
}
volume
}
}

View File

@ -15,6 +15,7 @@
#![cfg_attr(not(feature = "worldgen"), feature(const_panic))]
pub mod alias_validator;
pub mod automod;
mod character_creator;
pub mod chunk_generator;
mod chunk_serialize;
@ -55,6 +56,7 @@ pub use crate::{
use crate::terrain_persistence::TerrainPersistence;
use crate::{
alias_validator::AliasValidator,
automod::AutoMod,
chunk_generator::ChunkGenerator,
client::Client,
cmd::ChatCommandExt,
@ -339,7 +341,7 @@ impl Server {
state.ecs_mut().register::<RepositionOnChunkLoad>();
//Alias validator
let banned_words_paths = &settings.banned_words_files;
let banned_words_paths = &settings.moderation.banned_words_files;
let mut banned_words = Vec::new();
for path in banned_words_paths {
let mut list = match std::fs::File::open(&path) {
@ -367,7 +369,14 @@ impl Server {
let banned_words_count = banned_words.len();
debug!(?banned_words_count);
trace!(?banned_words);
state.ecs_mut().insert(AliasValidator::new(banned_words));
state
.ecs_mut()
.insert(AliasValidator::new(banned_words.clone()));
// Init automod
state
.ecs_mut()
.insert(AutoMod::new(&settings.moderation, banned_words));
#[cfg(feature = "worldgen")]
let (world, index) = World::generate(

View File

@ -95,6 +95,26 @@ impl Default for GameplaySettings {
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ModerationSettings {
#[serde(default)]
pub banned_words_files: Vec<PathBuf>,
#[serde(default)]
pub automod: bool,
#[serde(default)]
pub admins_exempt: bool,
}
impl Default for ModerationSettings {
fn default() -> Self {
Self {
banned_words_files: Vec::new(),
automod: false,
admins_exempt: true,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum CalendarMode {
None,
@ -132,7 +152,6 @@ pub struct Settings {
/// uses the value of the file options to decide how to proceed.
pub map_file: Option<FileOpts>,
pub max_view_distance: Option<u32>,
pub banned_words_files: Vec<PathBuf>,
pub max_player_group_size: u32,
pub client_timeout: Duration,
pub spawn_town: Option<String>,
@ -146,6 +165,8 @@ pub struct Settings {
#[serde(default)]
pub gameplay: GameplaySettings,
#[serde(default)]
pub moderation: ModerationSettings,
}
impl Default for Settings {
@ -167,7 +188,6 @@ impl Default for Settings {
start_time: 9.0 * 3600.0,
map_file: None,
max_view_distance: Some(65),
banned_words_files: Vec::new(),
max_player_group_size: 6,
calendar_mode: CalendarMode::Auto,
client_timeout: Duration::from_secs(40),
@ -175,6 +195,7 @@ impl Default for Settings {
max_player_for_kill_broadcast: None,
experimental_terrain_persistence: false,
gameplay: GameplaySettings::default(),
moderation: ModerationSettings::default(),
}
}
}

View File

@ -1,5 +1,6 @@
use crate::{
alias_validator::AliasValidator,
automod::AutoMod,
character_creator,
client::Client,
persistence::{character_loader::CharacterLoader, character_updater::CharacterUpdater},
@ -30,6 +31,7 @@ impl Sys {
presences: &ReadStorage<'_, Presence>,
editable_settings: &ReadExpect<'_, EditableSettings>,
alias_validator: &ReadExpect<'_, AliasValidator>,
automod: &AutoMod,
msg: ClientGeneral,
) -> Result<(), crate::error::Error> {
let mut send_join_messages = || -> Result<(), crate::error::Error> {
@ -41,6 +43,14 @@ impl Sys {
))?;
}
// Warn them about automod
if automod.enabled() {
client.send(ServerGeneral::server_msg(
ChatType::CommandInfo,
"Automatic moderation is enabled: play nice and have fun!",
))?;
}
if !client.login_msg_sent.load(Ordering::Relaxed) {
if let Some(player_uid) = uids.get(entity) {
server_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg {
@ -211,6 +221,7 @@ impl<'a> System<'a> for Sys {
ReadStorage<'a, Presence>,
ReadExpect<'a, EditableSettings>,
ReadExpect<'a, AliasValidator>,
ReadExpect<'a, AutoMod>,
);
const NAME: &'static str = "msg::character_screen";
@ -231,6 +242,7 @@ impl<'a> System<'a> for Sys {
presences,
editable_settings,
alias_validator,
automod,
): Self::SystemData,
) {
let mut server_emitter = server_event_bus.emitter();
@ -249,6 +261,7 @@ impl<'a> System<'a> for Sys {
&presences,
&editable_settings,
&alias_validator,
&automod,
msg,
)
});

View File

@ -1,32 +1,37 @@
use crate::client::Client;
use crate::{
automod::{self, AutoMod},
client::Client,
};
use common::{
comp::{ChatMode, Player},
comp::{Admin, AdminRole, ChatMode, ChatType, Player},
event::{EventBus, ServerEvent},
resources::Time,
uid::Uid,
};
use common_ecs::{Job, Origin, Phase, System};
use common_net::msg::{
validate_chat_msg, ChatMsgValidationError, ClientGeneral, MAX_BYTES_CHAT_MSG,
};
use specs::{Entities, Join, Read, ReadStorage};
use common_net::msg::{ClientGeneral, ServerGeneral};
use specs::{Entities, Join, Read, ReadStorage, WriteExpect};
use std::time::Instant;
use tracing::{debug, error, warn};
impl Sys {
fn handle_general_msg(
server_emitter: &mut common::event::Emitter<'_, ServerEvent>,
entity: specs::Entity,
_client: &Client,
client: &Client,
player: Option<&Player>,
admin_role: Option<AdminRole>,
uids: &ReadStorage<'_, Uid>,
chat_modes: &ReadStorage<'_, ChatMode>,
msg: ClientGeneral,
now: Instant,
automod: &mut AutoMod,
) -> Result<(), crate::error::Error> {
match msg {
ClientGeneral::ChatMsg(message) => {
if player.is_some() {
match validate_chat_msg(&message) {
Ok(()) => {
if let Some(player) = player {
match automod.validate_chat_msg(player.uuid(), admin_role, now, &message) {
Ok(note) => {
if let Some(from) = uids.get(entity) {
const CHAT_MODE_DEFAULT: &ChatMode = &ChatMode::default();
let mode = chat_modes.get(entity).unwrap_or(CHAT_MODE_DEFAULT);
@ -36,13 +41,42 @@ impl Sys {
} else {
error!("Could not send message. Missing player uid");
}
match note {
None => {},
Some(automod::ActionNote::SpamWarn) => {
let _ = client.send(ServerGeneral::server_msg(
ChatType::CommandError,
"You've sent a lot of messages recently. Make sure to \
reduce the rate of messages or you will be automatically \
muted.",
));
},
}
},
Err(ChatMsgValidationError::TooLong) => {
let max = MAX_BYTES_CHAT_MSG;
Err(automod::ActionErr::TooLong) => {
let len = message.len();
warn!(?len, ?max, "Received a chat message that's too long")
warn!(?len, "Received a chat message that's too long");
},
Err(automod::ActionErr::BannedWord) => {
let _ = client.send(ServerGeneral::server_msg(
ChatType::CommandError,
"Your message contained a banned word. If you think this is a \
false positive, please open a bug report.",
));
},
Err(automod::ActionErr::SpamMuted(dur)) => {
let _ = client.send(ServerGeneral::server_msg(
ChatType::CommandError,
format!(
"You have sent too many messages and are muted for {} seconds.",
dur.as_secs_f32() as u64
),
));
},
}
} else {
warn!("Received a chat message from an unregistered client");
}
},
ClientGeneral::Command(name, args) => {
@ -81,6 +115,8 @@ impl<'a> System<'a> for Sys {
ReadStorage<'a, ChatMode>,
ReadStorage<'a, Player>,
ReadStorage<'a, Client>,
ReadStorage<'a, Admin>,
WriteExpect<'a, AutoMod>,
);
const NAME: &'static str = "msg::general";
@ -89,20 +125,26 @@ impl<'a> System<'a> for Sys {
fn run(
_job: &mut Job<Self>,
(entities, server_event_bus, time, uids, chat_modes, players, clients): Self::SystemData,
(entities, server_event_bus, time, uids, chat_modes, players, clients, admins, mut automod): Self::SystemData,
) {
let mut server_emitter = server_event_bus.emitter();
for (entity, client, player) in (&entities, &clients, (&players).maybe()).join() {
let now = Instant::now();
for (entity, client, player, admin) in
(&entities, &clients, players.maybe(), admins.maybe()).join()
{
let res = super::try_recv_all(client, 3, |client, msg| {
Self::handle_general_msg(
&mut server_emitter,
entity,
client,
player,
admin.map(|a| a.0),
&uids,
&chat_modes,
msg,
now,
&mut automod,
)
});

View File

@ -9,7 +9,6 @@ use common::comp::{
group::Role,
BuffKind, ChatMode, ChatMsg, ChatType,
};
use common_net::msg::validate_chat_msg;
use conrod_core::{
color,
input::Key,
@ -120,9 +119,7 @@ impl<'a> Chat<'a> {
}
pub fn input(mut self, input: String) -> Self {
if let Ok(()) = validate_chat_msg(&input) {
self.force_input = Some(input);
}
self.force_input = Some(input);
self
}
@ -388,9 +385,7 @@ impl<'a> Widget for Chat<'a> {
.set(state.ids.chat_input, ui)
{
input.retain(|c| c != '\n');
if let Ok(()) = validate_chat_msg(&input) {
state.update(|s| s.input.message = input);
}
state.update(|s| s.input.message = input);
}
}