mirror of
https://gitlab.com/veloren/veloren.git
synced 2024-08-30 18:12:32 +00:00
Added automod
This commit is contained in:
parent
2d2d6b5c64
commit
52bd7b2485
10
Cargo.lock
generated
10
Cargo.lock
generated
@ -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",
|
||||
|
@ -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>) {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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
132
server/src/automod.rs
Normal 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
|
||||
}
|
||||
}
|
@ -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(
|
||||
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
)
|
||||
});
|
||||
|
@ -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,
|
||||
)
|
||||
});
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user