Merge branch 'zesterer/automod' into 'master'

Automod

See merge request veloren/veloren!3513
This commit is contained in:
Joshua Barretto 2022-08-11 10:56:18 +00:00
commit 900454552a
13 changed files with 510 additions and 415 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

@ -124,6 +124,28 @@ impl<G> ChatType<G> {
message: msg.into(),
}
}
pub fn uid(&self) -> Option<Uid> {
match self {
ChatType::Online(_) => None,
ChatType::Offline(_) => None,
ChatType::CommandInfo => None,
ChatType::CommandError => None,
ChatType::FactionMeta(_) => None,
ChatType::GroupMeta(_) => None,
ChatType::Kill(_, _) => None,
ChatType::Tell(u, _t) => Some(*u),
ChatType::Say(u) => Some(*u),
ChatType::Group(u, _s) => Some(*u),
ChatType::Faction(u, _s) => Some(*u),
ChatType::Region(u) => Some(*u),
ChatType::World(u) => Some(*u),
ChatType::Npc(u, _r) => Some(*u),
ChatType::NpcSay(u, _r) => Some(*u),
ChatType::NpcTell(u, _t, _r) => Some(*u),
ChatType::Meta => None,
}
}
}
// Stores chat text, type
@ -226,27 +248,7 @@ impl<G> GenericChatMsg<G> {
}
}
pub fn uid(&self) -> Option<Uid> {
match &self.chat_type {
ChatType::Online(_) => None,
ChatType::Offline(_) => None,
ChatType::CommandInfo => None,
ChatType::CommandError => None,
ChatType::FactionMeta(_) => None,
ChatType::GroupMeta(_) => None,
ChatType::Kill(_, _) => None,
ChatType::Tell(u, _t) => Some(*u),
ChatType::Say(u) => Some(*u),
ChatType::Group(u, _s) => Some(*u),
ChatType::Faction(u, _s) => Some(*u),
ChatType::Region(u) => Some(*u),
ChatType::World(u) => Some(*u),
ChatType::Npc(u, _r) => Some(*u),
ChatType::NpcSay(u, _r) => Some(*u),
ChatType::NpcTell(u, _t, _r) => Some(*u),
ChatType::Meta => None,
}
}
pub fn uid(&self) -> Option<Uid> { self.chat_type.uid() }
}
/// Player factions are used to coordinate pvp vs hostile factions or segment

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"] }

View File

@ -1,139 +0,0 @@
use common::character::MAX_NAME_LENGTH;
use std::fmt::{self, Display};
#[derive(Debug, Default)]
pub struct AliasValidator {
banned_substrings: Vec<String>,
}
impl AliasValidator {
pub fn new(banned_substrings: Vec<String>) -> Self {
let banned_substrings = banned_substrings
.iter()
.map(|string| string.to_lowercase())
.collect();
AliasValidator { banned_substrings }
}
pub fn validate(&self, alias: &str) -> Result<(), ValidatorError> {
if alias.len() > MAX_NAME_LENGTH {
return Err(ValidatorError::TooLong(alias.to_owned(), alias.len()));
}
let lowercase_alias = alias.to_lowercase();
for banned_word in self.banned_substrings.iter() {
if lowercase_alias.contains(banned_word) {
return Err(ValidatorError::Forbidden(
alias.to_owned(),
banned_word.to_owned(),
));
}
}
Ok(())
}
}
#[derive(Debug, PartialEq)]
pub enum ValidatorError {
Forbidden(String, String),
TooLong(String, usize),
}
impl Display for ValidatorError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Forbidden(name, _) => write!(
formatter,
"Character name \"{}\" contains a banned word",
name
),
Self::TooLong(name, _) => write!(formatter, "Character name \"{}\" too long", name),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn multiple_matches() {
let banned_substrings = vec!["bad".to_owned(), "worse".to_owned()];
let validator = AliasValidator::new(banned_substrings);
let bad_alias = "BadplayerMcWorseFace";
let result = validator.validate(bad_alias);
assert_eq!(
result,
Err(ValidatorError::Forbidden(
bad_alias.to_owned(),
"bad".to_owned()
))
);
}
#[test]
fn single_lowercase_match() {
let banned_substrings = vec!["blue".to_owned()];
let validator = AliasValidator::new(banned_substrings);
let bad_alias = "blueName";
let result = validator.validate(bad_alias);
assert_eq!(
result,
Err(ValidatorError::Forbidden(
bad_alias.to_owned(),
"blue".to_owned()
))
);
}
#[test]
fn single_case_insensitive_match() {
let banned_substrings = vec!["GrEEn".to_owned()];
let validator = AliasValidator::new(banned_substrings);
let bad_alias = "gReenName";
let result = validator.validate(bad_alias);
assert_eq!(
result,
Err(ValidatorError::Forbidden(
bad_alias.to_owned(),
"green".to_owned()
))
);
}
#[test]
fn mp_matches() {
let banned_substrings = vec!["orange".to_owned()];
let validator = AliasValidator::new(banned_substrings);
let good_alias = "ReasonableName";
let result = validator.validate(good_alias);
assert_eq!(result, Ok(()));
}
#[test]
fn too_long() {
let banned_substrings = vec!["orange".to_owned()];
let validator = AliasValidator::new(banned_substrings);
let bad_alias = "Thisnameistoolong Muchtoolong MuchTooLongByFar";
let result = validator.validate(bad_alias);
assert_eq!(
result,
Err(ValidatorError::TooLong(
bad_alias.to_owned(),
bad_alias.chars().count()
))
);
}
}

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

@ -0,0 +1,170 @@
use crate::settings::ModerationSettings;
use authc::Uuid;
use censor::Censor;
use common::comp::AdminRole;
use hashbrown::HashMap;
use std::{
fmt,
sync::Arc,
time::{Duration, Instant},
};
use tracing::info;
pub const MAX_BYTES_CHAT_MSG: usize = 256;
pub enum ActionNote {
SpamWarn,
}
impl fmt::Display for ActionNote {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ActionNote::SpamWarn => write!(
f,
"You've sent a lot of messages recently. Make sure to reduce the rate of messages \
or you will be automatically muted."
),
}
}
}
pub enum ActionErr {
BannedWord,
TooLong,
SpamMuted(Duration),
}
impl fmt::Display for ActionErr {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ActionErr::BannedWord => write!(
f,
"Your message contained a banned word. If you think this is a mistake, please let \
a moderator know."
),
ActionErr::TooLong => write!(
f,
"Your message was too long, no more than {} characters are permitted.",
MAX_BYTES_CHAT_MSG
),
ActionErr::SpamMuted(dur) => write!(
f,
"You have sent too many messages and are muted for {} seconds.",
dur.as_secs_f32() as u64
),
}
}
}
pub struct AutoMod {
settings: ModerationSettings,
censor: Arc<Censor>,
players: HashMap<Uuid, PlayerState>,
}
impl AutoMod {
pub fn new(settings: &ModerationSettings, censor: Arc<Censor>) -> 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,
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

@ -14,7 +14,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;
@ -54,7 +54,7 @@ pub use crate::{
#[cfg(feature = "persistent_world")]
use crate::terrain_persistence::TerrainPersistence;
use crate::{
alias_validator::AliasValidator,
automod::AutoMod,
chunk_generator::ChunkGenerator,
client::Client,
cmd::ChatCommandExt,
@ -68,6 +68,7 @@ use crate::{
state_ext::StateExt,
sys::sentinel::{DeletedEntities, TrackedStorages},
};
use censor::Censor;
#[cfg(not(feature = "worldgen"))]
use common::grid::Grid;
use common::{
@ -338,36 +339,15 @@ impl Server {
state.ecs_mut().register::<login_provider::PendingLogin>();
state.ecs_mut().register::<RepositionOnChunkLoad>();
//Alias validator
let banned_words_paths = &settings.banned_words_files;
let mut banned_words = Vec::new();
for path in banned_words_paths {
let mut list = match std::fs::File::open(&path) {
Ok(file) => match ron::de::from_reader(&file) {
Ok(vec) => vec,
Err(error) => {
warn!(?error, ?file, "Couldn't deserialize banned words file");
return Err(Error::Other(format!(
"Couldn't read banned words file \"{}\"",
path.to_string_lossy()
)));
},
},
Err(error) => {
warn!(?error, ?path, "Couldn't open banned words file");
return Err(Error::Other(format!(
"Couldn't open banned words file \"{}\". Error: {}",
path.to_string_lossy(),
error
)));
},
};
banned_words.append(&mut list);
}
let banned_words_count = banned_words.len();
debug!(?banned_words_count);
trace!(?banned_words);
state.ecs_mut().insert(AliasValidator::new(banned_words));
// Load banned words list
let banned_words = settings.moderation.load_banned_words(data_dir);
let censor = Arc::new(Censor::Custom(banned_words.into_iter().collect()));
state.ecs_mut().insert(Arc::clone(&censor));
// Init automod
state
.ecs_mut()
.insert(AutoMod::new(&settings.moderation, censor));
#[cfg(feature = "worldgen")]
let (world, index) = World::generate(

View File

@ -95,6 +95,44 @@ 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 ModerationSettings {
pub fn load_banned_words(&self, data_dir: &Path) -> Vec<String> {
let mut banned_words = Vec::new();
for fname in self.banned_words_files.iter() {
let mut path = with_config_dir(data_dir);
path.push(fname);
match std::fs::File::open(&path) {
Ok(file) => match ron::de::from_reader(&file) {
Ok(mut words) => banned_words.append(&mut words),
Err(error) => error!(?error, ?file, "Couldn't read banned words file"),
},
Err(error) => error!(?error, ?path, "Couldn't open banned words file"),
}
}
banned_words
}
}
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 +170,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 +183,8 @@ pub struct Settings {
#[serde(default)]
pub gameplay: GameplaySettings,
#[serde(default)]
pub moderation: ModerationSettings,
}
impl Default for Settings {
@ -167,7 +206,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 +213,7 @@ impl Default for Settings {
max_player_for_kill_broadcast: None,
experimental_terrain_persistence: false,
gameplay: GameplaySettings::default(),
moderation: ModerationSettings::default(),
}
}
}
@ -265,7 +304,7 @@ impl Settings {
}
}
fn with_config_dir(path: &Path) -> PathBuf {
pub fn with_config_dir(path: &Path) -> PathBuf {
let mut path = PathBuf::from(path);
path.push(CONFIG_DIR);
path

View File

@ -1,4 +1,5 @@
use crate::{
automod::AutoMod,
client::Client,
events::update_map_markers,
persistence::PersistedComponents,
@ -17,7 +18,7 @@ use common::{
self,
item::MaterialStatManifest,
skills::{GeneralSkill, Skill},
Group, Inventory, Item, Poise,
ChatType, Group, Inventory, Item, Player, Poise,
},
effect::Effect,
link::{Link, LinkHandle},
@ -36,7 +37,7 @@ use specs::{
saveload::MarkerAllocator, Builder, Entity as EcsEntity, EntityBuilder as EcsEntityBuilder,
Join, WorldExt,
};
use std::time::Duration;
use std::time::{Duration, Instant};
use tracing::{trace, warn};
use vek::*;
@ -113,6 +114,7 @@ pub trait StateExt {
/// Performed after loading component data from the database
fn update_character_data(&mut self, entity: EcsEntity, components: PersistedComponents);
/// Iterates over registered clients and send each `ServerMsg`
fn validate_chat_msg(&self, player: EcsEntity, msg: &str) -> bool;
fn send_chat(&self, msg: comp::UnresolvedChatMsg);
fn notify_players(&self, msg: ServerGeneral);
fn notify_in_game_clients(&self, msg: ServerGeneral);
@ -674,6 +676,39 @@ impl StateExt for State {
}
}
fn validate_chat_msg(&self, entity: EcsEntity, msg: &str) -> bool {
let mut automod = self.ecs().write_resource::<AutoMod>();
let Some(client) = self.ecs().read_storage::<Client>().get(entity) else { return true };
let Some(player) = self.ecs().read_storage::<Player>().get(entity) else { return true };
match automod.validate_chat_msg(
player.uuid(),
self.ecs()
.read_storage::<comp::Admin>()
.get(entity)
.map(|a| a.0),
Instant::now(),
msg,
) {
Ok(note) => {
if let Some(note) = note {
let _ = client.send(ServerGeneral::server_msg(
ChatType::CommandInfo,
format!("{}", note),
));
}
true
},
Err(err) => {
let _ = client.send(ServerGeneral::server_msg(
ChatType::CommandError,
format!("{}", err),
));
false
},
}
}
/// Send the chat message to the proper players. Say and region are limited
/// by location. Faction and group are limited by component.
fn send_chat(&self, msg: comp::UnresolvedChatMsg) {
@ -689,165 +724,183 @@ impl StateExt for State {
.clone()
.map_group(|_| group_info.map_or_else(|| "???".to_string(), |i| i.name.clone()));
match &msg.chat_type {
comp::ChatType::Offline(_)
| comp::ChatType::CommandInfo
| comp::ChatType::CommandError
| comp::ChatType::Meta
| comp::ChatType::World(_) => self.notify_players(ServerGeneral::ChatMsg(resolved_msg)),
comp::ChatType::Online(u) => {
for (client, uid) in
(&ecs.read_storage::<Client>(), &ecs.read_storage::<Uid>()).join()
{
if uid != u {
client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
if msg.chat_type.uid().map_or(true, |sender| {
(*ecs.read_resource::<UidAllocator>())
.retrieve_entity_internal(sender.0)
.map_or(false, |e| self.validate_chat_msg(e, &msg.message))
}) {
match &msg.chat_type {
comp::ChatType::Offline(_)
| comp::ChatType::CommandInfo
| comp::ChatType::CommandError
| comp::ChatType::Meta
| comp::ChatType::World(_) => {
self.notify_players(ServerGeneral::ChatMsg(resolved_msg))
},
comp::ChatType::Online(u) => {
for (client, uid) in
(&ecs.read_storage::<Client>(), &ecs.read_storage::<Uid>()).join()
{
if uid != u {
client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
}
}
}
},
comp::ChatType::Tell(u, t) => {
for (client, uid) in
(&ecs.read_storage::<Client>(), &ecs.read_storage::<Uid>()).join()
{
if uid == u || uid == t {
client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
}
}
},
comp::ChatType::Kill(kill_source, uid) => {
let comp::chat::GenericChatMsg { message, .. } = msg;
let clients = ecs.read_storage::<Client>();
let clients_count = clients.count();
// Avoid chat spam, send kill message only to group or nearby players if a
// certain amount of clients are online
if clients_count
> ecs
.fetch::<Settings>()
.max_player_for_kill_broadcast
.unwrap_or_default()
{
// Send kill message to the dead player's group
let killed_entity =
(*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0);
let groups = ecs.read_storage::<Group>();
let killed_group = killed_entity.and_then(|e| groups.get(e));
if let Some(g) = &killed_group {
send_to_group(g, ecs, &resolved_msg);
},
comp::ChatType::Tell(from, to) => {
for (client, uid) in
(&ecs.read_storage::<Client>(), &ecs.read_storage::<Uid>()).join()
{
if uid == from || uid == to {
client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
}
}
},
comp::ChatType::Kill(kill_source, uid) => {
let comp::chat::GenericChatMsg { message, .. } = msg;
let clients = ecs.read_storage::<Client>();
let clients_count = clients.count();
// Avoid chat spam, send kill message only to group or nearby players if a
// certain amount of clients are online
if clients_count
> ecs
.fetch::<Settings>()
.max_player_for_kill_broadcast
.unwrap_or_default()
{
// Send kill message to the dead player's group
let killed_entity =
(*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0);
let groups = ecs.read_storage::<Group>();
let killed_group = killed_entity.and_then(|e| groups.get(e));
if let Some(g) = &killed_group {
send_to_group(g, ecs, &resolved_msg);
}
// Send kill message to nearby players that aren't part of the deceased's group
let positions = ecs.read_storage::<comp::Pos>();
if let Some(died_player_pos) = killed_entity.and_then(|e| positions.get(e)) {
for (ent, client, pos) in (&*ecs.entities(), &clients, &positions).join() {
let client_group = groups.get(ent);
let is_different_group =
!(killed_group == client_group && client_group.is_some());
if is_within(comp::ChatMsg::SAY_DISTANCE, pos, died_player_pos)
&& is_different_group
// Send kill message to nearby players that aren't part of the deceased's
// group
let positions = ecs.read_storage::<comp::Pos>();
if let Some(died_player_pos) = killed_entity.and_then(|e| positions.get(e))
{
for (ent, client, pos) in
(&*ecs.entities(), &clients, &positions).join()
{
let client_group = groups.get(ent);
let is_different_group =
!(killed_group == client_group && client_group.is_some());
if is_within(comp::ChatMsg::SAY_DISTANCE, pos, died_player_pos)
&& is_different_group
{
client.send_fallible(ServerGeneral::ChatMsg(
resolved_msg.clone(),
));
}
}
}
} else {
self.notify_players(ServerGeneral::server_msg(
comp::ChatType::Kill(kill_source.clone(), *uid),
message,
))
}
},
comp::ChatType::Say(uid) => {
let entity_opt =
(*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0);
let positions = ecs.read_storage::<comp::Pos>();
if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) {
for (client, pos) in (&ecs.read_storage::<Client>(), &positions).join() {
if is_within(comp::ChatMsg::SAY_DISTANCE, pos, speaker_pos) {
client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
}
}
}
} else {
self.notify_players(ServerGeneral::server_msg(
comp::ChatType::Kill(kill_source.clone(), *uid),
message,
))
}
},
comp::ChatType::Say(uid) => {
let entity_opt =
(*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0);
let positions = ecs.read_storage::<comp::Pos>();
if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) {
for (client, pos) in (&ecs.read_storage::<Client>(), &positions).join() {
if is_within(comp::ChatMsg::SAY_DISTANCE, pos, speaker_pos) {
client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
}
}
}
},
comp::ChatType::Region(uid) => {
let entity_opt =
(*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0);
let positions = ecs.read_storage::<comp::Pos>();
if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) {
for (client, pos) in (&ecs.read_storage::<Client>(), &positions).join() {
if is_within(comp::ChatMsg::REGION_DISTANCE, pos, speaker_pos) {
client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
}
}
}
},
comp::ChatType::Npc(uid, _r) => {
let entity_opt =
(*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0);
let positions = ecs.read_storage::<comp::Pos>();
if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) {
for (client, pos) in (&ecs.read_storage::<Client>(), &positions).join() {
if is_within(comp::ChatMsg::NPC_DISTANCE, pos, speaker_pos) {
client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
}
}
}
},
comp::ChatType::NpcSay(uid, _r) => {
let entity_opt =
(*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0);
let positions = ecs.read_storage::<comp::Pos>();
if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) {
for (client, pos) in (&ecs.read_storage::<Client>(), &positions).join() {
if is_within(comp::ChatMsg::NPC_SAY_DISTANCE, pos, speaker_pos) {
client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
}
}
}
},
comp::ChatType::NpcTell(from, to, _r) => {
for (client, uid) in
(&ecs.read_storage::<Client>(), &ecs.read_storage::<Uid>()).join()
{
if uid == from || uid == to {
client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
}
}
},
comp::ChatType::FactionMeta(s) | comp::ChatType::Faction(_, s) => {
for (client, faction) in (
&ecs.read_storage::<Client>(),
&ecs.read_storage::<comp::Faction>(),
)
.join()
{
if s == &faction.0 {
client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
}
}
},
comp::ChatType::Group(from, g) => {
if group_info.is_none() {
// group not found, reply with command error
let reply = comp::ChatMsg {
chat_type: comp::ChatType::CommandError,
message: "You are using group chat but do not belong to a group. Use \
/world or /region to change chat."
.into(),
};
},
comp::ChatType::Region(uid) => {
let entity_opt =
(*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0);
if let Some((client, _)) =
(&ecs.read_storage::<Client>(), &ecs.read_storage::<Uid>())
.join()
.find(|(_, uid)| *uid == from)
{
client.send_fallible(ServerGeneral::ChatMsg(reply));
let positions = ecs.read_storage::<comp::Pos>();
if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) {
for (client, pos) in (&ecs.read_storage::<Client>(), &positions).join() {
if is_within(comp::ChatMsg::REGION_DISTANCE, pos, speaker_pos) {
client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
}
}
}
return;
}
send_to_group(g, ecs, &resolved_msg);
},
comp::ChatType::GroupMeta(g) => {
send_to_group(g, ecs, &resolved_msg);
},
},
comp::ChatType::Npc(uid, _r) => {
let entity_opt =
(*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0);
let positions = ecs.read_storage::<comp::Pos>();
if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) {
for (client, pos) in (&ecs.read_storage::<Client>(), &positions).join() {
if is_within(comp::ChatMsg::NPC_DISTANCE, pos, speaker_pos) {
client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
}
}
}
},
comp::ChatType::NpcSay(uid, _r) => {
let entity_opt =
(*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0);
let positions = ecs.read_storage::<comp::Pos>();
if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) {
for (client, pos) in (&ecs.read_storage::<Client>(), &positions).join() {
if is_within(comp::ChatMsg::NPC_SAY_DISTANCE, pos, speaker_pos) {
client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
}
}
}
},
comp::ChatType::NpcTell(from, to, _r) => {
for (client, uid) in
(&ecs.read_storage::<Client>(), &ecs.read_storage::<Uid>()).join()
{
if uid == from || uid == to {
client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
}
}
},
comp::ChatType::FactionMeta(s) | comp::ChatType::Faction(_, s) => {
for (client, faction) in (
&ecs.read_storage::<Client>(),
&ecs.read_storage::<comp::Faction>(),
)
.join()
{
if s == &faction.0 {
client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
}
}
},
comp::ChatType::Group(from, g) => {
if group_info.is_none() {
// group not found, reply with command error
let reply = comp::ChatMsg {
chat_type: comp::ChatType::CommandError,
message: "You are using group chat but do not belong to a group. Use \
/world or /region to change chat."
.into(),
};
if let Some((client, _)) =
(&ecs.read_storage::<Client>(), &ecs.read_storage::<Uid>())
.join()
.find(|(_, uid)| *uid == from)
{
client.send_fallible(ServerGeneral::ChatMsg(reply));
}
return;
}
send_to_group(g, ecs, &resolved_msg);
},
comp::ChatType::GroupMeta(g) => {
send_to_group(g, ecs, &resolved_msg);
},
}
}
}

View File

@ -1,5 +1,5 @@
use crate::{
alias_validator::AliasValidator,
automod::AutoMod,
character_creator,
client::Client,
persistence::{character_loader::CharacterLoader, character_updater::CharacterUpdater},
@ -14,7 +14,7 @@ use common::{
use common_ecs::{Job, Origin, Phase, System};
use common_net::msg::{ClientGeneral, ServerGeneral};
use specs::{Entities, Join, Read, ReadExpect, ReadStorage, WriteExpect};
use std::sync::atomic::Ordering;
use std::sync::{atomic::Ordering, Arc};
use tracing::debug;
impl Sys {
@ -29,7 +29,8 @@ impl Sys {
admins: &ReadStorage<'_, Admin>,
presences: &ReadStorage<'_, Presence>,
editable_settings: &ReadExpect<'_, EditableSettings>,
alias_validator: &ReadExpect<'_, AliasValidator>,
censor: &ReadExpect<'_, Arc<censor::Censor>>,
automod: &AutoMod,
msg: ClientGeneral,
) -> Result<(), crate::error::Error> {
let mut send_join_messages = || -> Result<(), crate::error::Error> {
@ -41,6 +42,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 {
@ -128,9 +137,12 @@ impl Sys {
offhand,
body,
} => {
if let Err(error) = alias_validator.validate(&alias) {
debug!(?error, ?alias, "denied alias as it contained a banned word");
client.send(ServerGeneral::CharacterActionError(error.to_string()))?;
if censor.check(&alias) {
debug!(?alias, "denied alias as it contained a banned word");
client.send(ServerGeneral::CharacterActionError(format!(
"Alias '{}' contains a banned word",
alias
)))?;
} else if let Some(player) = players.get(entity) {
if let Err(error) = character_creator::create_character(
entity,
@ -153,9 +165,12 @@ impl Sys {
}
},
ClientGeneral::EditCharacter { id, alias, body } => {
if let Err(error) = alias_validator.validate(&alias) {
debug!(?error, ?alias, "denied alias as it contained a banned word");
client.send(ServerGeneral::CharacterActionError(error.to_string()))?;
if censor.check(&alias) {
debug!(?alias, "denied alias as it contained a banned word");
client.send(ServerGeneral::CharacterActionError(format!(
"Alias '{}' contains a banned word",
alias
)))?;
} else if let Some(player) = players.get(entity) {
if let Err(error) = character_creator::edit_character(
entity,
@ -210,7 +225,8 @@ impl<'a> System<'a> for Sys {
ReadStorage<'a, Admin>,
ReadStorage<'a, Presence>,
ReadExpect<'a, EditableSettings>,
ReadExpect<'a, AliasValidator>,
ReadExpect<'a, Arc<censor::Censor>>,
ReadExpect<'a, AutoMod>,
);
const NAME: &'static str = "msg::character_screen";
@ -230,7 +246,8 @@ impl<'a> System<'a> for Sys {
admins,
presences,
editable_settings,
alias_validator,
censor,
automod,
): Self::SystemData,
) {
let mut server_emitter = server_event_bus.emitter();
@ -248,7 +265,8 @@ impl<'a> System<'a> for Sys {
&admins,
&presences,
&editable_settings,
&alias_validator,
&censor,
&automod,
msg,
)
});

View File

@ -6,9 +6,7 @@ use common::{
uid::Uid,
};
use common_ecs::{Job, Origin, Phase, System};
use common_net::msg::{
validate_chat_msg, ChatMsgValidationError, ClientGeneral, MAX_BYTES_CHAT_MSG,
};
use common_net::msg::ClientGeneral;
use specs::{Entities, Join, Read, ReadStorage};
use tracing::{debug, error, warn};
@ -25,24 +23,16 @@ impl Sys {
match msg {
ClientGeneral::ChatMsg(message) => {
if player.is_some() {
match validate_chat_msg(&message) {
Ok(()) => {
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);
// Send chat message
server_emitter
.emit(ServerEvent::Chat(mode.new_message(*from, message)));
} else {
error!("Could not send message. Missing player uid");
}
},
Err(ChatMsgValidationError::TooLong) => {
let max = MAX_BYTES_CHAT_MSG;
let len = message.len();
warn!(?len, ?max, "Received a chat message that's too long")
},
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);
// Send chat message
server_emitter.emit(ServerEvent::Chat(mode.new_message(*from, message)));
} else {
error!("Could not send message. Missing player uid");
}
} else {
warn!("Received a chat message from an unregistered client");
}
},
ClientGeneral::Command(name, args) => {
@ -93,7 +83,7 @@ impl<'a> System<'a> for Sys {
) {
let mut server_emitter = server_event_bus.emitter();
for (entity, client, player) in (&entities, &clients, (&players).maybe()).join() {
for (entity, client, player) in (&entities, &clients, players.maybe()).join() {
let res = super::try_recv_all(client, 3, |client, msg| {
Self::handle_general_msg(
&mut server_emitter,

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);
}
}