Compare commits

...
Author SHA1 Message Date
Horizon agent 9ec09e4714 Merge remote-tracking branch 'origin/layers/moderation-durations' into layers/chat-guard-hook
# Conflicts:
#	crates/horizon-client/src/game/chat_filter.rs
2026-11-09 19:26:44 +01:00
rdbtCVSandClaude Sonnet 5.5 1a4e885e2c Merge main into layers/chat-guard-hook
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 17:26:02 +00:00
rdbtCVSandClaude Sonnet 5.5 db4a145e6b cargo fmt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 17:22:31 +00:00
Horizon agentandClaude Opus 5.5 1414d6350f Chat filter: the hub asks an engine hook, horizon-client-lib no longer names horizon-chat-filter
Layer plan M6. horizon-server-files gets chat_guard (the ChatGuard trait, Place, Action, Mute, and an
install/load registry). horizon-chat-filter implements the trait and its loader; entry installs it
at startup. Behaviour unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:03:42 +01:00
Horizon agentandClaude Opus 5.5 f2f02e57bd Moderation durations move from horizon-chat-filter to horizon-server-files
Layer plan M6, a pure move before the chat filter's engine hook: /tempban (engine) and /tempmute
share parse_duration, duration_parts, duration_text and now_millis, which now live in the engine's
horizon-server-files (duration.rs). horizon-chat-filter re-exports them. Behaviour unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:02:55 +01:00
16 changed files with 377 additions and 189 deletions

No files matched your search

Generated
+1 -1
View File
@@ -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",
+2
View File
@@ -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"
+139 -91
View File
@@ -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);
-2
View File
@@ -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).
+2 -1
View File
@@ -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" }
+2
View File
@@ -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
+13 -18
View File
@@ -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()
+8 -28
View File
@@ -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.
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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)
}
+2
View File
@@ -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;
-1
View File
@@ -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",