mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 17:19:15 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ec09e4714 | ||
|
|
1a4e885e2c | ||
|
|
db4a145e6b | ||
|
|
1414d6350f | ||
|
|
f2f02e57bd |
No files matched your search
Generated
+1
-1
@@ -1418,6 +1418,7 @@ dependencies = [
|
||||
name = "horizon-chat-filter"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-server-files",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
@@ -1522,7 +1523,6 @@ dependencies = [
|
||||
"horizon-anticheat",
|
||||
"horizon-assets",
|
||||
"horizon-bench",
|
||||
"horizon-chat-filter",
|
||||
"horizon-client-core",
|
||||
"horizon-client-input",
|
||||
"horizon-client-render",
|
||||
|
||||
@@ -7,6 +7,8 @@ rust-version.workspace = true
|
||||
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
|
||||
|
||||
[dependencies]
|
||||
# The hook the hub asks (`chat_guard`), and the moderation durations.
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "1.1"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! The dedicated server's chat filter (docs/chat-filter.md), without the game: `server.toml`'s
|
||||
//! `[chat_filter]`, the matcher for the slur list (`data/slurs.txt`, or the owner's `words_file`), and
|
||||
//! the escalation (a refused message, then a mute, then a temporary ban), with the mutes kept in a file
|
||||
//! so that they outlive a restart. The server's hub (`horizon_client::lan`) asks it before it relays a
|
||||
//! player's text and carries out what it decides.
|
||||
//! so that they outlive a restart. The server's hub (`horizon_client::lan`) asks it, through the engine's
|
||||
//! `horizon_server_files::chat_guard` hook, before it relays a player's text, and carries out what it decides.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
@@ -10,6 +10,11 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
mod normalise;
|
||||
|
||||
use horizon_server_files::chat_guard::{self, ChatGuard, Loaded, Place};
|
||||
pub use horizon_server_files::chat_guard::{Action, Mute};
|
||||
pub use horizon_server_files::duration::{
|
||||
DurationUnit, duration_parts, duration_text, now_millis, parse_duration,
|
||||
};
|
||||
pub use normalise::{Run, Text};
|
||||
|
||||
/// The list built into the game, used when `words_file` is empty.
|
||||
@@ -218,49 +223,59 @@ fn fits(entry: &[Run], runs: &[Run], whole: bool) -> bool {
|
||||
.all(|(e, r)| e.letter == r.letter && r.count >= e.count)
|
||||
}
|
||||
|
||||
/// What a hit leads to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// The text is refused, nothing more.
|
||||
Refused,
|
||||
/// Refused, and the player can't chat until then (epoch milliseconds).
|
||||
Muted { until: i64 },
|
||||
/// Refused, and the player is banned until then.
|
||||
Banned { until: i64 },
|
||||
/// A mute as the mutes file keeps it.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
struct SavedMute {
|
||||
uuid: String,
|
||||
name: String,
|
||||
until: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
by: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
reason: Option<String>,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Action::Refused => "refused",
|
||||
Action::Muted { .. } => "muted",
|
||||
Action::Banned { .. } => "banned",
|
||||
impl From<SavedMute> for Mute {
|
||||
fn from(m: SavedMute) -> Self {
|
||||
let SavedMute {
|
||||
uuid,
|
||||
name,
|
||||
until,
|
||||
by,
|
||||
reason,
|
||||
} = m;
|
||||
Mute {
|
||||
uuid,
|
||||
name,
|
||||
until,
|
||||
by,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Mute {
|
||||
pub uuid: String,
|
||||
pub name: String,
|
||||
/// Epoch milliseconds; `None`: until `/unmute`.
|
||||
pub until: Option<i64>,
|
||||
/// The operator who muted them (`/mute`, `/tempmute`); `None`: the filter.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub by: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
impl Mute {
|
||||
pub fn active(&self, now: i64) -> bool {
|
||||
self.until.is_none_or(|until| until > now)
|
||||
impl From<Mute> for SavedMute {
|
||||
fn from(m: Mute) -> Self {
|
||||
let Mute {
|
||||
uuid,
|
||||
name,
|
||||
until,
|
||||
by,
|
||||
reason,
|
||||
} = m;
|
||||
SavedMute {
|
||||
uuid,
|
||||
name,
|
||||
until,
|
||||
by,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct MutesFile {
|
||||
mutes: Vec<Mute>,
|
||||
mutes: Vec<SavedMute>,
|
||||
}
|
||||
|
||||
/// One player's hits: how many, and when the last one stops counting.
|
||||
@@ -290,7 +305,7 @@ impl Guard {
|
||||
{
|
||||
for m in saved.mutes {
|
||||
if let Some(uuid) = parse_uuid(&m.uuid) {
|
||||
mutes.insert(uuid, m);
|
||||
mutes.insert(uuid, m.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -428,7 +443,7 @@ impl Guard {
|
||||
};
|
||||
let now = now_millis();
|
||||
self.mutes.retain(|_, m| m.active(now));
|
||||
let mut mutes: Vec<Mute> = self.mutes.values().cloned().collect();
|
||||
let mut mutes: Vec<SavedMute> = self.mutes.values().cloned().map(Into::into).collect();
|
||||
mutes.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
if let Ok(text) = serde_json::to_string_pretty(&MutesFile { mutes }) {
|
||||
let tmp = file.with_extension("json.tmp");
|
||||
@@ -439,6 +454,94 @@ impl Guard {
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatGuard for Guard {
|
||||
fn matches(&self, text: &str) -> bool {
|
||||
self.filter.matches(text)
|
||||
}
|
||||
|
||||
fn muted(&self, uuid: u128, now: i64) -> Option<&Mute> {
|
||||
Guard::muted(self, uuid, now)
|
||||
}
|
||||
|
||||
fn mutes(&self, now: i64) -> Vec<&Mute> {
|
||||
Guard::mutes(self, now)
|
||||
}
|
||||
|
||||
fn mute(
|
||||
&mut self,
|
||||
uuid: u128,
|
||||
name: &str,
|
||||
until: Option<i64>,
|
||||
by: &str,
|
||||
reason: Option<String>,
|
||||
) {
|
||||
Guard::mute(self, uuid, name, until, by, reason);
|
||||
}
|
||||
|
||||
fn strike(&mut self, uuid: u128, name: &str, now: i64) -> (u32, Action) {
|
||||
Guard::strike(self, uuid, name, now)
|
||||
}
|
||||
|
||||
fn unmute(&mut self, name: &str) -> Option<String> {
|
||||
Guard::unmute(self, name)
|
||||
}
|
||||
|
||||
fn log(
|
||||
&mut self,
|
||||
now: i64,
|
||||
name: &str,
|
||||
uuid: u128,
|
||||
place: Place,
|
||||
count: u32,
|
||||
action: Action,
|
||||
text: &str,
|
||||
) -> Result<(), String> {
|
||||
let path = &self.config.log;
|
||||
if path.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let line = log_line(now, name, uuid, place.name(), count, action, text);
|
||||
append_log(Path::new(path), &line).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// `server.toml`'s `[chat_filter]` for the dedicated server ([`chat_guard::install`] it at startup): on
|
||||
/// by default, off (with an error logged) when the section is wrong. The mutes are kept in `dir`.
|
||||
pub fn load(server_toml: &str, dir: &Path) -> Loaded {
|
||||
let mut loaded = Loaded::default();
|
||||
let config = match Config::from_server_toml(server_toml) {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
loaded.log.push((
|
||||
"ERROR",
|
||||
format!("server.toml: {e}; the chat filter stays off"),
|
||||
));
|
||||
return loaded;
|
||||
}
|
||||
};
|
||||
if !config.enabled {
|
||||
return loaded;
|
||||
}
|
||||
let (guard, problem) = Guard::load(config, dir);
|
||||
if let Some(problem) = problem {
|
||||
loaded.log.push(("WARN", problem));
|
||||
}
|
||||
loaded.log.push((
|
||||
"INFO",
|
||||
format!(
|
||||
"Chat filter on: {} words (server.toml [chat_filter]; enabled = false turns it off)",
|
||||
guard.filter.len()
|
||||
),
|
||||
));
|
||||
loaded.guard = Some(Box::new(guard));
|
||||
loaded
|
||||
}
|
||||
|
||||
/// Gives the dedicated server this chat filter.
|
||||
pub fn install() {
|
||||
chat_guard::install(load);
|
||||
}
|
||||
|
||||
/// One line of `logs/chat-filter.log`, without its newline.
|
||||
pub fn log_line(
|
||||
now: i64,
|
||||
@@ -474,61 +577,6 @@ pub fn append_log(path: &Path, line: &str) -> std::io::Result<()> {
|
||||
writeln!(f, "{line}")
|
||||
}
|
||||
|
||||
/// `/tempmute`'s duration in milliseconds: a positive whole number and a unit, `s`, `m`, `h`, `d` or
|
||||
/// `w` (`10m`, `2h`, `1d`).
|
||||
pub fn parse_duration(text: &str) -> Option<i64> {
|
||||
let unit = match text.chars().last()? {
|
||||
's' => 1_000,
|
||||
'm' => MINUTE,
|
||||
'h' => 60 * MINUTE,
|
||||
'd' => 24 * 60 * MINUTE,
|
||||
'w' => 7 * 24 * 60 * MINUTE,
|
||||
_ => return None,
|
||||
};
|
||||
let count: i64 = text[..text.len() - 1].parse().ok()?;
|
||||
(count > 0).then(|| count.checked_mul(unit)).flatten()
|
||||
}
|
||||
|
||||
/// The unit used when presenting a moderation duration to a player.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DurationUnit {
|
||||
Minute,
|
||||
Hour,
|
||||
Day,
|
||||
}
|
||||
|
||||
/// A duration's rounded count and unit, shared by logs and translated replies.
|
||||
pub fn duration_parts(millis: i64) -> (i64, DurationUnit) {
|
||||
let (unit, kind) = if millis <= 60 * MINUTE {
|
||||
(MINUTE, DurationUnit::Minute)
|
||||
} else if millis <= 48 * 60 * MINUTE {
|
||||
(60 * MINUTE, DurationUnit::Hour)
|
||||
} else {
|
||||
(24 * 60 * MINUTE, DurationUnit::Day)
|
||||
};
|
||||
(
|
||||
millis.div_euclid(unit) + i64::from(millis.rem_euclid(unit) > 0),
|
||||
kind,
|
||||
)
|
||||
}
|
||||
|
||||
/// `millis` for logs, rounded up: minutes up to an hour, hours up to two days, then days.
|
||||
pub fn duration_text(millis: i64) -> String {
|
||||
let (count, unit) = duration_parts(millis);
|
||||
let unit = match unit {
|
||||
DurationUnit::Minute => "minute",
|
||||
DurationUnit::Hour => "hour",
|
||||
DurationUnit::Day => "day",
|
||||
};
|
||||
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
|
||||
}
|
||||
|
||||
pub fn now_millis() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as i64)
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DDTHH:MM:SSZ` for epoch milliseconds.
|
||||
pub fn utc(millis: i64) -> String {
|
||||
let secs = millis.div_euclid(1000);
|
||||
|
||||
@@ -36,8 +36,6 @@ horizon-server-files = { path = "../horizon-server-files" }
|
||||
getrandom = "0.3"
|
||||
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
|
||||
horizon-anticheat = { path = "../horizon-anticheat" }
|
||||
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
|
||||
horizon-chat-filter = { path = "../horizon-chat-filter" }
|
||||
# Shader packs: reading them and turning their GLSL into naga modules.
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
|
||||
|
||||
@@ -33,7 +33,8 @@ horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
|
||||
horizon-anticheat = { path = "../horizon-anticheat" }
|
||||
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
|
||||
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md);
|
||||
# `entry` installs it into the engine's hook (`horizon_server_files::chat_guard`).
|
||||
horizon-chat-filter = { path = "../horizon-chat-filter" }
|
||||
# Shader packs: reading them and turning their GLSL into naga modules.
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
|
||||
@@ -13,6 +13,8 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
log::warn!("Trait mods already installed; ignoring the second list");
|
||||
}
|
||||
horizon_threads::configure_allocator();
|
||||
// The platform's features the dedicated server loads from server.toml, through the engine's hooks.
|
||||
horizon_chat_filter::install();
|
||||
// horizon-extras (docs/forking.md): without the feature, no Horizon Settings or Mods page.
|
||||
if !cfg!(feature = "horizon-extras") {
|
||||
horizon_client::options_ui::HORIZON_EXTRAS
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
//! (never as `chat`), a temporary ban goes into banned-players.json, and operators mute and unmute
|
||||
//! players by hand with `/mute`, `/tempmute`, `/mutelist` and `/unmute` (the same words on the console).
|
||||
use super::{Game, dedicated, lan::LanHost};
|
||||
use horizon_chat_filter::Action;
|
||||
use horizon_client::lan::hub::{HostEvent, chat_filter::Place};
|
||||
use horizon_client::text::Text;
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
use horizon_protocol::text::{literal, translatable};
|
||||
use horizon_server_files::chat_guard::{self, Action};
|
||||
use horizon_server_files::user_lists::{BanEntry, NameAndId};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
@@ -42,8 +42,8 @@ impl Reply {
|
||||
}
|
||||
|
||||
pub(super) fn duration(millis: i64) -> Text {
|
||||
use horizon_chat_filter::DurationUnit;
|
||||
let (count, unit) = horizon_chat_filter::duration_parts(millis);
|
||||
use chat_guard::DurationUnit;
|
||||
let (count, unit) = chat_guard::duration_parts(millis);
|
||||
let key = match (unit, count == 1) {
|
||||
(DurationUnit::Minute, true) => "horizon.moderation.duration.minute",
|
||||
(DurationUnit::Minute, false) => "horizon.moderation.duration.minutes",
|
||||
@@ -91,10 +91,7 @@ pub(super) const MUTE_USAGES: [&str; 4] = [
|
||||
/// `/mute <player> [duration] [reason]` (`words`, the command first): how long, when the word after
|
||||
/// the name is a duration (`10m`, `2h`, `1d`; none: until `/unmute`), and the index the reason starts at.
|
||||
fn mute_time(words: &[&str]) -> (Option<i64>, usize) {
|
||||
match words
|
||||
.get(2)
|
||||
.and_then(|w| horizon_chat_filter::parse_duration(w))
|
||||
{
|
||||
match words.get(2).and_then(|w| chat_guard::parse_duration(w)) {
|
||||
Some(ms) => (Some(ms), 3),
|
||||
None => (None, 2),
|
||||
}
|
||||
@@ -133,7 +130,7 @@ impl Game<'_> {
|
||||
count: u32,
|
||||
action: Action,
|
||||
) {
|
||||
let now = horizon_chat_filter::now_millis();
|
||||
let now = chat_guard::now_millis();
|
||||
let summary = format!(
|
||||
"[chat-filter] Refused {name}'s {} (hit {count}: {})",
|
||||
place.name(),
|
||||
@@ -145,12 +142,10 @@ impl Game<'_> {
|
||||
}
|
||||
// The text only goes to the filter's own log, for operators: never to the console or the stream.
|
||||
dedicated::log("INFO", &summary);
|
||||
if let Some(path) = host.hub.chat_filter_log() {
|
||||
let line =
|
||||
horizon_chat_filter::log_line(now, name, uuid, place.name(), count, action, text);
|
||||
if let Err(e) = horizon_chat_filter::append_log(std::path::Path::new(path), &line) {
|
||||
dedicated::log("WARN", &format!("[chat-filter] {path}: {e}"));
|
||||
}
|
||||
if let Some(guard) = host.hub.chat_filter.as_deref_mut()
|
||||
&& let Err(e) = guard.log(now, name, uuid, place, count, action, text)
|
||||
{
|
||||
dedicated::log("WARN", &format!("[chat-filter] {e}"));
|
||||
}
|
||||
dedicated::events::record(
|
||||
"chat_filtered",
|
||||
@@ -281,7 +276,7 @@ impl Game<'_> {
|
||||
let reason = |from: usize| (words.len() > from).then(|| words[from..].join(" "));
|
||||
match words {
|
||||
["mutelist"] => {
|
||||
let now = horizon_chat_filter::now_millis();
|
||||
let now = chat_guard::now_millis();
|
||||
let mutes = host.hub.mutes();
|
||||
if mutes.is_empty() {
|
||||
return vec![Reply::translated("horizon.moderation.mutelist.empty", &[])];
|
||||
@@ -319,7 +314,7 @@ impl Game<'_> {
|
||||
let (time, from) = mute_time(words);
|
||||
vec![self.mute(host, name, time, reason(from), by)]
|
||||
}
|
||||
["tempmute", name, time, ..] => match horizon_chat_filter::parse_duration(time) {
|
||||
["tempmute", name, time, ..] => match chat_guard::parse_duration(time) {
|
||||
Some(ms) => vec![self.mute(host, name, Some(ms), reason(3), by)],
|
||||
None => vec![Reply::translated(
|
||||
"horizon.moderation.invalid_duration",
|
||||
@@ -353,10 +348,10 @@ impl Game<'_> {
|
||||
let Some((uuid, name)) = user else {
|
||||
return Reply::no_player();
|
||||
};
|
||||
let until = time.map(|ms| horizon_chat_filter::now_millis() + ms);
|
||||
let until = time.map(|ms| chat_guard::now_millis() + ms);
|
||||
let what = time.map_or_else(
|
||||
|| "until unmuted".into(),
|
||||
|ms| format!("for {}", horizon_chat_filter::duration_text(ms)),
|
||||
|ms| format!("for {}", chat_guard::duration_text(ms)),
|
||||
);
|
||||
let why = reason
|
||||
.as_deref()
|
||||
|
||||
@@ -208,35 +208,15 @@ fn load_anticheat() -> horizon_anticheat::Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// `server.toml`'s `[chat_filter]` (docs/chat-filter.md), like `[anticheat]`: on by default, off (with an
|
||||
/// error logged) when the section is wrong. The mutes are kept beside server.properties.
|
||||
fn load_chat_filter() -> Option<horizon_chat_filter::Guard> {
|
||||
/// `server.toml`'s `[chat_filter]` (docs/chat-filter.md), through the filter the executable installed
|
||||
/// (`horizon_server_files::chat_guard`): none without one.
|
||||
fn load_chat_filter() -> Option<Box<dyn horizon_server_files::chat_guard::ChatGuard>> {
|
||||
let text = std::fs::read_to_string("server.toml").unwrap_or_default();
|
||||
let config = match horizon_chat_filter::Config::from_server_toml(&text) {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
log(
|
||||
"ERROR",
|
||||
&format!("server.toml: {e}; the chat filter stays off"),
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if !config.enabled {
|
||||
return None;
|
||||
let loaded = horizon_server_files::chat_guard::load(&text, std::path::Path::new("."));
|
||||
for (level, line) in &loaded.log {
|
||||
log(level, line);
|
||||
}
|
||||
let (guard, problem) = horizon_chat_filter::Guard::load(config, std::path::Path::new("."));
|
||||
if let Some(problem) = problem {
|
||||
log("WARN", &problem);
|
||||
}
|
||||
log(
|
||||
"INFO",
|
||||
&format!(
|
||||
"Chat filter on: {} words (server.toml [chat_filter]; enabled = false turns it off)",
|
||||
guard.filter.len()
|
||||
),
|
||||
);
|
||||
Some(guard)
|
||||
loaded.guard
|
||||
}
|
||||
|
||||
/// `server.toml`'s `[combat]` (docs/pvp.md): `legacy = true` resolves melee hits by 1.8.9's rules. Off without
|
||||
@@ -2671,7 +2651,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_temporarily_banned_player_is_refused_with_the_end_until_it_has_passed() {
|
||||
use super::{BanEntry, BanList, Lists, NameAndId};
|
||||
use horizon_chat_filter::now_millis;
|
||||
use horizon_server_files::duration::now_millis;
|
||||
let dir = std::env::temp_dir().join(format!("horizon-can-login-{}", std::process::id()));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let mut lists = Lists {
|
||||
|
||||
@@ -702,7 +702,7 @@ impl Game<'_> {
|
||||
host.hub
|
||||
.chat_filter
|
||||
.as_ref()
|
||||
.is_none_or(|g| !g.filter.matches(text)),
|
||||
.is_none_or(|g| !g.matches(text)),
|
||||
)
|
||||
}
|
||||
/// A system line in every player's chat and the server's log.
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
//! the time left. Vanilla has no such commands: they reach operators' command trees (completions included)
|
||||
//! as a native mod's commands do (`moderation_commands`), and the game runs them itself.
|
||||
use super::{Game, chat_filter::Reply, dedicated, lan::LanHost};
|
||||
use horizon_chat_filter::{duration_text, now_millis, parse_duration};
|
||||
use horizon_client::{mod_api::NativeCommand, text::Text};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
use horizon_protocol::text::translatable;
|
||||
use horizon_server_files::duration::{duration_text, now_millis, parse_duration};
|
||||
use horizon_server_files::user_lists::{BanEntry, BanList, NameAndId, format_date};
|
||||
|
||||
/// The operator level `/tempban` needs, as `/ban`'s.
|
||||
|
||||
@@ -223,7 +223,7 @@ pub enum HostEvent {
|
||||
text: String,
|
||||
/// The hit's number in the filter's window.
|
||||
count: u32,
|
||||
action: horizon_chat_filter::Action,
|
||||
action: horizon_server_files::chat_guard::Action,
|
||||
},
|
||||
/// A server log warning (ServerGamePacketListenerImpl's LOGGER.warn).
|
||||
Warn(String),
|
||||
@@ -348,7 +348,7 @@ pub struct Hub {
|
||||
/// The anticheat, when `server.toml` turns it on (dedicated servers).
|
||||
pub anticheat: Option<Box<super::anticheat::Anticheat>>,
|
||||
/// The chat filter, when `server.toml` turns it on (dedicated servers).
|
||||
pub chat_filter: Option<Box<horizon_chat_filter::Guard>>,
|
||||
pub chat_filter: Option<Box<dyn horizon_server_files::chat_guard::ChatGuard>>,
|
||||
/// The far ring, when `server.toml`'s `max_view_distance` turns it on (dedicated servers).
|
||||
pub far: Option<super::far::Far>,
|
||||
/// The `immediate_respawn` game rule, as the players were last told.
|
||||
|
||||
@@ -1,43 +1,16 @@
|
||||
//! The chat filter on the server side (docs/chat-filter.md): a player's text, before the server relays
|
||||
//! it, through `horizon_chat_filter`'s list, mutes and escalation. A refused text reaches nobody; the
|
||||
//! sender is told in private, and the game logs it and bans when the escalation says so
|
||||
//! (`HostEvent::ChatFiltered`).
|
||||
//! it, through the server's [`ChatGuard`] (the platform's `horizon-chat-filter` on a dedicated server):
|
||||
//! its list, mutes and escalation. A refused text reaches nobody; the sender is told in private, and the
|
||||
//! game logs it and bans when the escalation says so (`HostEvent::ChatFiltered`).
|
||||
use super::{HostEvent, Hub, chat::now_millis};
|
||||
use horizon_chat_filter::{Action, Guard, Mute, duration_text};
|
||||
use horizon_protocol::{
|
||||
packets::play::{self, Clientbound as C},
|
||||
text::literal,
|
||||
};
|
||||
pub use horizon_server_files::chat_guard::Place;
|
||||
use horizon_server_files::chat_guard::{Action, ChatGuard, Mute, duration_text};
|
||||
use horizon_world::nbt::{JavaString, Tag};
|
||||
|
||||
/// Where a player's text was going.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Place {
|
||||
/// A chat message.
|
||||
Chat,
|
||||
/// A command's message: `/msg`, `/tell`, `/w`, `/me`, `/say`, `/teammsg`.
|
||||
Command,
|
||||
Sign,
|
||||
Anvil,
|
||||
Book,
|
||||
}
|
||||
|
||||
impl Place {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Place::Chat => "chat",
|
||||
Place::Command => "command",
|
||||
Place::Sign => "sign",
|
||||
Place::Anvil => "anvil",
|
||||
Place::Book => "book",
|
||||
}
|
||||
}
|
||||
/// Chat and commands: a muted player can't use them.
|
||||
fn is_chat(self) -> bool {
|
||||
matches!(self, Place::Chat | Place::Command)
|
||||
}
|
||||
}
|
||||
|
||||
/// What the sender reads when their text held a slur.
|
||||
pub fn notice(place: Place, action: Action, now: i64) -> String {
|
||||
let refused = match place {
|
||||
@@ -91,8 +64,8 @@ fn red(text: &str) -> Tag {
|
||||
|
||||
impl Hub {
|
||||
/// `server.toml`'s `[chat_filter]` (dedicated servers); `None` turns it off.
|
||||
pub fn set_chat_filter(&mut self, guard: Option<Guard>) {
|
||||
self.chat_filter = guard.map(Box::new);
|
||||
pub fn set_chat_filter(&mut self, guard: Option<Box<dyn ChatGuard>>) {
|
||||
self.chat_filter = guard;
|
||||
}
|
||||
|
||||
pub fn chat_filter_on(&self) -> bool {
|
||||
@@ -112,7 +85,7 @@ impl Hub {
|
||||
return true;
|
||||
};
|
||||
let (uuid, now) = (self.guests[i].uuid, now_millis());
|
||||
let hit = texts.iter().any(|t| guard.filter.matches(t));
|
||||
let hit = texts.iter().any(|t| guard.matches(t));
|
||||
let muted = place.is_chat().then(|| guard.muted(uuid, now)).flatten();
|
||||
if !hit {
|
||||
let Some(mute) = muted else {
|
||||
@@ -188,14 +161,6 @@ impl Hub {
|
||||
pub fn unmute(&mut self, name: &str) -> Option<String> {
|
||||
self.chat_filter.as_deref_mut()?.unmute(name)
|
||||
}
|
||||
|
||||
/// The `[chat_filter]` log's path, relative to the server's folder.
|
||||
pub fn chat_filter_log(&self) -> Option<&str> {
|
||||
self.chat_filter
|
||||
.as_deref()
|
||||
.map(|g| g.config.log.as_str())
|
||||
.filter(|l| !l.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! The hook a dedicated server's chat filter fills: the hub asks a [`ChatGuard`] about each text a player
|
||||
//! sends before it relays it, and carries out what it decides. The engine has no filter of its own; the
|
||||
//! platform's `horizon-chat-filter` ([docs/chat-filter.md]) installs one ([`install`]) and the dedicated
|
||||
//! server loads it from `server.toml` ([`load`]). Without one the hub relays every text, as vanilla's does.
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub use crate::duration::{
|
||||
DurationUnit, duration_parts, duration_text, now_millis, parse_duration,
|
||||
};
|
||||
|
||||
/// Where a player's text was going.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Place {
|
||||
/// A chat message.
|
||||
Chat,
|
||||
/// A command's message: `/msg`, `/tell`, `/w`, `/me`, `/say`, `/teammsg`.
|
||||
Command,
|
||||
Sign,
|
||||
Anvil,
|
||||
Book,
|
||||
}
|
||||
|
||||
impl Place {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Place::Chat => "chat",
|
||||
Place::Command => "command",
|
||||
Place::Sign => "sign",
|
||||
Place::Anvil => "anvil",
|
||||
Place::Book => "book",
|
||||
}
|
||||
}
|
||||
|
||||
/// Chat and commands: a muted player can't use them.
|
||||
pub fn is_chat(self) -> bool {
|
||||
matches!(self, Place::Chat | Place::Command)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a hit leads to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// The text is refused, nothing more.
|
||||
Refused,
|
||||
/// Refused, and the player can't chat until then (epoch milliseconds).
|
||||
Muted { until: i64 },
|
||||
/// Refused, and the player is banned until then.
|
||||
Banned { until: i64 },
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Action::Refused => "refused",
|
||||
Action::Muted { .. } => "muted",
|
||||
Action::Banned { .. } => "banned",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A player's mute.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Mute {
|
||||
pub uuid: String,
|
||||
pub name: String,
|
||||
/// Epoch milliseconds; `None`: until `/unmute`.
|
||||
pub until: Option<i64>,
|
||||
/// The operator who muted them (`/mute`, `/tempmute`); `None`: the filter.
|
||||
pub by: Option<String>,
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
impl Mute {
|
||||
pub fn active(&self, now: i64) -> bool {
|
||||
self.until.is_none_or(|until| until > now)
|
||||
}
|
||||
}
|
||||
|
||||
/// A server's chat filter: its word list, the players' hits and the mutes.
|
||||
pub trait ChatGuard: Send + Sync {
|
||||
/// Whether `text` holds a word of the list.
|
||||
fn matches(&self, text: &str) -> bool;
|
||||
/// The player's mute, if they are muted at `now`.
|
||||
fn muted(&self, uuid: u128, now: i64) -> Option<&Mute>;
|
||||
/// The mutes in force at `now`, by name.
|
||||
fn mutes(&self, now: i64) -> Vec<&Mute>;
|
||||
/// An operator's `/mute` (`until` `None`) or `/tempmute`: replaces the player's mute.
|
||||
fn mute(
|
||||
&mut self,
|
||||
uuid: u128,
|
||||
name: &str,
|
||||
until: Option<i64>,
|
||||
by: &str,
|
||||
reason: Option<String>,
|
||||
);
|
||||
/// A hit by the player at `now`: the hit's number, and what it leads to.
|
||||
fn strike(&mut self, uuid: u128, name: &str, now: i64) -> (u32, Action);
|
||||
/// Lifts a player's mute, by name; the name as muted, if they were.
|
||||
fn unmute(&mut self, name: &str) -> Option<String>;
|
||||
/// Writes a refused text to the filter's own log, when it keeps one; `Err` says why it couldn't.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn log(
|
||||
&mut self,
|
||||
now: i64,
|
||||
name: &str,
|
||||
uuid: u128,
|
||||
place: Place,
|
||||
count: u32,
|
||||
action: Action,
|
||||
text: &str,
|
||||
) -> Result<(), String>;
|
||||
}
|
||||
|
||||
/// What [`load`] gives: the filter (`None`: off) and the lines to log, as (level, line).
|
||||
#[derive(Default)]
|
||||
pub struct Loaded {
|
||||
pub guard: Option<Box<dyn ChatGuard>>,
|
||||
pub log: Vec<(&'static str, String)>,
|
||||
}
|
||||
|
||||
/// Reads a filter's settings from `server.toml`'s text, its files from the server's folder `dir`.
|
||||
pub type Loader = fn(server_toml: &str, dir: &Path) -> Loaded;
|
||||
|
||||
static LOADER: OnceLock<Loader> = OnceLock::new();
|
||||
|
||||
/// Gives the dedicated server its chat filter, once, before it starts; a second call is ignored.
|
||||
pub fn install(loader: Loader) {
|
||||
let _ = LOADER.set(loader);
|
||||
}
|
||||
|
||||
/// The installed filter for this `server.toml`; none without one.
|
||||
pub fn load(server_toml: &str, dir: &Path) -> Loaded {
|
||||
LOADER
|
||||
.get()
|
||||
.map_or_else(Loaded::default, |load| load(server_toml, dir))
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Moderation durations: `/tempmute` and `/tempban`'s argument, and how long a mute or ban lasts when the
|
||||
//! server tells it.
|
||||
|
||||
const MINUTE: i64 = 60_000;
|
||||
|
||||
/// `/tempmute`'s duration in milliseconds: a positive whole number and a unit, `s`, `m`, `h`, `d` or
|
||||
/// `w` (`10m`, `2h`, `1d`).
|
||||
pub fn parse_duration(text: &str) -> Option<i64> {
|
||||
let unit = match text.chars().last()? {
|
||||
's' => 1_000,
|
||||
'm' => MINUTE,
|
||||
'h' => 60 * MINUTE,
|
||||
'd' => 24 * 60 * MINUTE,
|
||||
'w' => 7 * 24 * 60 * MINUTE,
|
||||
_ => return None,
|
||||
};
|
||||
let count: i64 = text[..text.len() - 1].parse().ok()?;
|
||||
(count > 0).then(|| count.checked_mul(unit)).flatten()
|
||||
}
|
||||
|
||||
/// The unit used when presenting a moderation duration to a player.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DurationUnit {
|
||||
Minute,
|
||||
Hour,
|
||||
Day,
|
||||
}
|
||||
|
||||
/// A duration's rounded count and unit, shared by logs and translated replies.
|
||||
pub fn duration_parts(millis: i64) -> (i64, DurationUnit) {
|
||||
let (unit, kind) = if millis <= 60 * MINUTE {
|
||||
(MINUTE, DurationUnit::Minute)
|
||||
} else if millis <= 48 * 60 * MINUTE {
|
||||
(60 * MINUTE, DurationUnit::Hour)
|
||||
} else {
|
||||
(24 * 60 * MINUTE, DurationUnit::Day)
|
||||
};
|
||||
(
|
||||
millis.div_euclid(unit) + i64::from(millis.rem_euclid(unit) > 0),
|
||||
kind,
|
||||
)
|
||||
}
|
||||
|
||||
/// `millis` for logs, rounded up: minutes up to an hour, hours up to two days, then days.
|
||||
pub fn duration_text(millis: i64) -> String {
|
||||
let (count, unit) = duration_parts(millis);
|
||||
let unit = match unit {
|
||||
DurationUnit::Minute => "minute",
|
||||
DurationUnit::Hour => "hour",
|
||||
DurationUnit::Day => "day",
|
||||
};
|
||||
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
|
||||
}
|
||||
|
||||
pub fn now_millis() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as i64)
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
//! looking players up by name. The game's dedicated server
|
||||
//! (`horizon-client --dedicated`) reads them, and `horizon-server` manages
|
||||
//! them; this crate needs neither a window nor the game.
|
||||
pub mod chat_guard;
|
||||
pub mod duration;
|
||||
pub mod eula;
|
||||
pub mod java_properties;
|
||||
pub mod json;
|
||||
|
||||
@@ -98,7 +98,6 @@
|
||||
"horizon-client-input -> horizon-theme",
|
||||
"horizon-client-lib -> horizon-anticheat",
|
||||
"horizon-client-lib -> horizon-bench",
|
||||
"horizon-client-lib -> horizon-chat-filter",
|
||||
"horizon-client-lib -> horizon-friends",
|
||||
"horizon-client-lib -> horizon-hdr",
|
||||
"horizon-client-lib -> horizon-mcp",
|
||||
|
||||
Reference in new issue
Block a user