mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Server: /mute, /tempmute and /mutelist for ops
This commit is contained in:
1 parent
3a264943c7
commit
4d0c0fb6d1
8 files changed
+403
-85
No files matched your search
@@ -239,8 +239,19 @@ impl Action {
|
||||
pub struct Mute {
|
||||
pub uuid: String,
|
||||
pub name: String,
|
||||
/// Epoch milliseconds.
|
||||
pub until: i64,
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
@@ -312,12 +323,38 @@ impl Guard {
|
||||
)
|
||||
}
|
||||
|
||||
/// When the player's mute ends, if they are muted at `now`.
|
||||
pub fn muted_until(&self, uuid: u128, now: i64) -> Option<i64> {
|
||||
self.mutes
|
||||
.get(&uuid)
|
||||
.map(|m| m.until)
|
||||
.filter(|&until| until > now)
|
||||
/// The player's mute, if they are muted at `now`.
|
||||
pub fn muted(&self, uuid: u128, now: i64) -> Option<&Mute> {
|
||||
self.mutes.get(&uuid).filter(|m| m.active(now))
|
||||
}
|
||||
|
||||
/// The mutes in force at `now`, by name.
|
||||
pub fn mutes(&self, now: i64) -> Vec<&Mute> {
|
||||
let mut mutes: Vec<&Mute> = self.mutes.values().filter(|m| m.active(now)).collect();
|
||||
mutes.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
mutes
|
||||
}
|
||||
|
||||
/// An operator's `/mute` (`until` `None`) or `/tempmute`: replaces the player's mute.
|
||||
pub fn mute(
|
||||
&mut self,
|
||||
uuid: u128,
|
||||
name: &str,
|
||||
until: Option<i64>,
|
||||
by: &str,
|
||||
reason: Option<String>,
|
||||
) {
|
||||
self.mutes.insert(
|
||||
uuid,
|
||||
Mute {
|
||||
uuid: uuid_string(uuid),
|
||||
name: name.to_owned(),
|
||||
until,
|
||||
by: Some(by.to_owned()),
|
||||
reason,
|
||||
},
|
||||
);
|
||||
self.save();
|
||||
}
|
||||
|
||||
/// A hit by the player at `now`: the hit's number in the window, and what it leads to.
|
||||
@@ -343,15 +380,24 @@ impl Guard {
|
||||
let until = now + i64::from(c.mute_minutes) * MINUTE;
|
||||
// The window starts again when the mute ends.
|
||||
s.counts_until = until + window;
|
||||
self.mutes.insert(
|
||||
uuid,
|
||||
Mute {
|
||||
uuid: uuid_string(uuid),
|
||||
name: name.to_owned(),
|
||||
until,
|
||||
},
|
||||
);
|
||||
self.save();
|
||||
// An operator's longer mute stays.
|
||||
if !self
|
||||
.mutes
|
||||
.get(&uuid)
|
||||
.is_some_and(|m| m.until.is_none_or(|u| u >= until))
|
||||
{
|
||||
self.mutes.insert(
|
||||
uuid,
|
||||
Mute {
|
||||
uuid: uuid_string(uuid),
|
||||
name: name.to_owned(),
|
||||
until: Some(until),
|
||||
by: None,
|
||||
reason: None,
|
||||
},
|
||||
);
|
||||
self.save();
|
||||
}
|
||||
Action::Muted { until }
|
||||
} else {
|
||||
Action::Refused
|
||||
@@ -377,7 +423,7 @@ impl Guard {
|
||||
return;
|
||||
};
|
||||
let now = now_millis();
|
||||
self.mutes.retain(|_, m| m.until > now);
|
||||
self.mutes.retain(|_, m| m.active(now));
|
||||
let mut mutes: Vec<Mute> = self.mutes.values().cloned().collect();
|
||||
mutes.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
if let Ok(text) = serde_json::to_string_pretty(&MutesFile { mutes }) {
|
||||
@@ -424,6 +470,34 @@ 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()
|
||||
}
|
||||
|
||||
/// `millis` for players, rounded up: minutes up to an hour, hours up to two days, then days.
|
||||
pub fn duration_text(millis: i64) -> String {
|
||||
let up = |unit: i64| (millis + unit - 1) / unit;
|
||||
let (count, unit) = if millis <= 60 * MINUTE {
|
||||
(up(MINUTE), "minute")
|
||||
} else if millis <= 48 * 60 * MINUTE {
|
||||
(up(60 * MINUTE), "hour")
|
||||
} else {
|
||||
(up(24 * 60 * MINUTE), "day")
|
||||
};
|
||||
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
|
||||
}
|
||||
|
||||
pub fn now_millis() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
|
||||
@@ -120,14 +120,14 @@ fn the_second_hit_mutes_and_the_third_bans() {
|
||||
let mut g = guard(Config::default());
|
||||
let id = 7;
|
||||
assert_eq!(g.strike(id, "Ann", NOW), (1, Action::Refused));
|
||||
assert_eq!(g.muted_until(id, NOW), None);
|
||||
assert_eq!(g.muted(id, NOW), None);
|
||||
let until = NOW + 3 * MIN + 10 * MIN;
|
||||
assert_eq!(
|
||||
g.strike(id, "Ann", NOW + 3 * MIN),
|
||||
(2, Action::Muted { until })
|
||||
);
|
||||
assert_eq!(g.muted_until(id, NOW + 4 * MIN), Some(until));
|
||||
assert_eq!(g.muted_until(id, until), None);
|
||||
assert_eq!(g.muted(id, NOW + 4 * MIN).unwrap().until, Some(until));
|
||||
assert_eq!(g.muted(id, until), None);
|
||||
// The time spent muted doesn't count: 8 minutes after the mute ended is still within the window.
|
||||
assert_eq!(
|
||||
g.strike(id, "Ann", until + 8 * MIN),
|
||||
@@ -190,15 +190,83 @@ fn mutes_survive_a_restart_and_unmute_lifts_them() {
|
||||
let (_, action) = g.strike(5, "Dee", now);
|
||||
assert!(matches!(action, Action::Muted { .. }));
|
||||
let (mut again, _) = Guard::load(Config::default(), &dir);
|
||||
assert!(again.muted_until(5, now).is_some());
|
||||
assert!(again.muted(5, now).is_some());
|
||||
assert_eq!(again.unmute("dee").as_deref(), Some("Dee"));
|
||||
assert_eq!(again.unmute("dee"), None);
|
||||
assert_eq!(again.muted_until(5, now), None);
|
||||
assert_eq!(again.muted(5, now), None);
|
||||
// An operator's mutes are kept too, with who and why.
|
||||
again.mute(6, "Eve", None, "Op", Some("spam".into()));
|
||||
again.mute(7, "Fay", Some(now + MIN), "Op", None);
|
||||
let (reloaded, _) = Guard::load(Config::default(), &dir);
|
||||
assert_eq!(reloaded.muted_until(5, now), None);
|
||||
assert_eq!(reloaded.muted(5, now), None);
|
||||
let names: Vec<_> = reloaded
|
||||
.mutes(now)
|
||||
.iter()
|
||||
.map(|m| m.name.as_str())
|
||||
.collect();
|
||||
assert_eq!(names, ["Eve", "Fay"]);
|
||||
let eve = reloaded.muted(6, now).unwrap();
|
||||
assert_eq!(
|
||||
(eve.until, eve.by.as_deref(), eve.reason.as_deref()),
|
||||
(None, Some("Op"), Some("spam"))
|
||||
);
|
||||
assert_eq!(reloaded.mutes(now + MIN).len(), 1);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mute_file_from_before_manual_mutes_still_loads() {
|
||||
let saved: MutesFile = serde_json::from_str(
|
||||
r#"{"mutes":[{"uuid":"00000000-0000-0000-0000-000000000005","name":"Dee","until":5}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(saved.mutes[0].until, Some(5));
|
||||
assert_eq!(saved.mutes[0].by, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_filter_never_shortens_an_operators_mute() {
|
||||
let mut g = guard(Config::default());
|
||||
g.mute(1, "Bo", None, "Op", None);
|
||||
g.strike(1, "Bo", NOW);
|
||||
g.strike(1, "Bo", NOW + MIN);
|
||||
assert_eq!(
|
||||
g.muted(1, NOW + 60 * MIN).unwrap().by.as_deref(),
|
||||
Some("Op")
|
||||
);
|
||||
// A shorter one is replaced by the filter's.
|
||||
g.mute(2, "Cy", Some(NOW + MIN), "Op", None);
|
||||
g.strike(2, "Cy", NOW);
|
||||
g.strike(2, "Cy", NOW);
|
||||
assert_eq!(g.muted(2, NOW).unwrap().until, Some(NOW + 10 * MIN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durations_read_and_print() {
|
||||
assert_eq!(parse_duration("10m"), Some(10 * MIN));
|
||||
assert_eq!(parse_duration("2h"), Some(120 * MIN));
|
||||
assert_eq!(parse_duration("1d"), Some(24 * 60 * MIN));
|
||||
assert_eq!(parse_duration("1w"), Some(7 * 24 * 60 * MIN));
|
||||
assert_eq!(parse_duration("30s"), Some(30_000));
|
||||
for bad in [
|
||||
"",
|
||||
"m",
|
||||
"0m",
|
||||
"-1m",
|
||||
"1.5h",
|
||||
"10",
|
||||
"10x",
|
||||
"99999999999999999w",
|
||||
] {
|
||||
assert_eq!(parse_duration(bad), None, "{bad}");
|
||||
}
|
||||
assert_eq!(duration_text(61_000), "2 minutes");
|
||||
assert_eq!(duration_text(MIN), "1 minute");
|
||||
assert_eq!(duration_text(2 * 60 * MIN), "2 hours");
|
||||
assert_eq!(duration_text(24 * 60 * MIN), "24 hours");
|
||||
assert_eq!(duration_text(3 * 24 * 60 * MIN), "3 days");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_words_file_falls_back_to_the_built_in_list() {
|
||||
let config = Config {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The dedicated server's chat filter, game side (docs/chat-filter.md): what the hub refused is
|
||||
//! written to `logs/chat-filter.log` and told to horizon-server's event stream as `chat_filtered`
|
||||
//! (never as `chat`), a temporary ban goes into banned-players.json, and operators lift mutes with
|
||||
//! `/unmute <player>` (or `unmute` on the console).
|
||||
//! (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};
|
||||
@@ -9,8 +9,11 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
use horizon_protocol::text::{literal, translatable};
|
||||
use horizon_server_files::user_lists::{BanEntry, NameAndId};
|
||||
|
||||
/// The operator level `/unmute` needs, as `/ban`'s.
|
||||
const UNMUTE_LEVEL: u8 = 3;
|
||||
/// The operator level the mute commands need, as `/ban`'s.
|
||||
const MUTE_LEVEL: u8 = 3;
|
||||
|
||||
/// The commands of `mute_command`.
|
||||
pub(super) const MUTE_COMMANDS: [&str; 4] = ["mute", "tempmute", "mutelist", "unmute"];
|
||||
|
||||
/// The reason a temporary ban gives at login.
|
||||
const BAN_REASON: &str = "Hate speech in chat (Horizon chat filter)";
|
||||
@@ -155,27 +158,114 @@ impl Game<'_> {
|
||||
allowed
|
||||
}
|
||||
|
||||
/// `/unmute <player>` from a guest: operators of level 3 (`/ban`'s); vanilla's unknown command
|
||||
/// for the others.
|
||||
pub(super) fn guest_unmute(&mut self, host: &mut LanHost, conn: u32, words: &[&str]) {
|
||||
/// A mute command from a guest: operators of level 3 (`/ban`'s); vanilla's unknown command for
|
||||
/// the others.
|
||||
pub(super) fn guest_mute_command(&mut self, host: &mut LanHost, conn: u32, words: &[&str]) {
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return;
|
||||
};
|
||||
let (permission, by) = (g.permission, g.name.clone());
|
||||
let reply = if permission < UNMUTE_LEVEL {
|
||||
translatable("command.unknown.command", &[])
|
||||
} else if words.len() != 2 {
|
||||
literal("/unmute <player>")
|
||||
let replies = if permission < MUTE_LEVEL {
|
||||
vec![translatable("command.unknown.command", &[])]
|
||||
} else {
|
||||
literal(&self.unmute(host, words[1], &by))
|
||||
let words: Vec<&str> = words.iter().copied().filter(|w| !w.is_empty()).collect();
|
||||
self.mute_command(host, &words, &by)
|
||||
.iter()
|
||||
.map(|line| literal(line))
|
||||
.collect()
|
||||
};
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
content: reply,
|
||||
overlay: false,
|
||||
}),
|
||||
for content in replies {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
content,
|
||||
overlay: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `/mute <player> [reason]`, `/tempmute <player> <duration> [reason]`, `/mutelist` and
|
||||
/// `/unmute <player>` (`words`, the command first) by `by`: the lines to answer.
|
||||
pub(super) fn mute_command(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
words: &[&str],
|
||||
by: &str,
|
||||
) -> Vec<String> {
|
||||
let reason = |from: usize| (words.len() > from).then(|| words[from..].join(" "));
|
||||
match words {
|
||||
["mutelist"] => {
|
||||
let now = horizon_chat_filter::now_millis();
|
||||
let mutes = host.hub.mutes();
|
||||
if mutes.is_empty() {
|
||||
return vec!["There are no mutes".into()];
|
||||
}
|
||||
let mut lines = vec![format!("There are {} mute(s):", mutes.len())];
|
||||
for m in mutes {
|
||||
let by = m.by.as_deref().unwrap_or("the chat filter");
|
||||
let until = m.until.map_or_else(
|
||||
|| "until unmuted".into(),
|
||||
|u| {
|
||||
let left = horizon_chat_filter::duration_text(u - now);
|
||||
format!("for {}", left.replacen(' ', " more ", 1))
|
||||
},
|
||||
);
|
||||
let why = m.reason.map(|r| format!(": {r}")).unwrap_or_default();
|
||||
lines.push(format!("{} was muted by {by} {until}{why}", m.name));
|
||||
}
|
||||
lines
|
||||
}
|
||||
["unmute", name] => vec![self.unmute(host, name, by)],
|
||||
["mute", name, ..] => vec![self.mute(host, name, None, reason(2), by)],
|
||||
["tempmute", name, time, ..] => match horizon_chat_filter::parse_duration(time) {
|
||||
Some(ms) => vec![self.mute(host, name, Some(ms), reason(3), by)],
|
||||
None => vec![format!("{time} isn't a duration: 10m, 2h, 1d")],
|
||||
},
|
||||
["mute", ..] => vec!["/mute <player> [reason]".into()],
|
||||
["tempmute", ..] => vec!["/tempmute <player> <duration> [reason]".into()],
|
||||
["unmute", ..] => vec!["/unmute <player>".into()],
|
||||
_ => vec!["/mutelist".into()],
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutes `name` for `time` milliseconds (`None`: until `/unmute`), logged; what to answer.
|
||||
fn mute(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
name: &str,
|
||||
time: Option<i64>,
|
||||
reason: Option<String>,
|
||||
by: &str,
|
||||
) -> String {
|
||||
// A player here, else one the server has seen (the user cache).
|
||||
let user = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.name.eq_ignore_ascii_case(name))
|
||||
.map(|g| (g.uuid, g.name.clone()))
|
||||
.or_else(|| self.resolve_name(name).map(|u| (u.id, u.name)));
|
||||
let Some((uuid, name)) = user else {
|
||||
return format!("No player was found named {name}");
|
||||
};
|
||||
let until = time.map(|ms| horizon_chat_filter::now_millis() + ms);
|
||||
let what = time.map_or_else(
|
||||
|| "until unmuted".into(),
|
||||
|ms| format!("for {}", horizon_chat_filter::duration_text(ms)),
|
||||
);
|
||||
let why = reason
|
||||
.as_deref()
|
||||
.map(|r| format!(": {r}"))
|
||||
.unwrap_or_default();
|
||||
host.hub.mute(uuid, &name, until, by, reason);
|
||||
if self.options.dedicated.is_some() {
|
||||
dedicated::log(
|
||||
"INFO",
|
||||
&format!("[chat-filter] {by} muted {name} {what}{why}"),
|
||||
);
|
||||
}
|
||||
format!("Muted {name} {what}{why}")
|
||||
}
|
||||
|
||||
/// Lifts `name`'s mute, logged; what to answer.
|
||||
|
||||
@@ -1457,13 +1457,17 @@ impl Game<'_> {
|
||||
}
|
||||
"whitelist" => self.console_whitelist(state, &rest),
|
||||
// The chat filter's (game/chat_filter.rs), on servers that filter.
|
||||
"unmute"
|
||||
if rest.len() == 1 && self.lan.as_ref().is_some_and(|h| h.hub.chat_filter_on()) =>
|
||||
command
|
||||
if super::chat_filter::MUTE_COMMANDS.contains(&command)
|
||||
&& self.lan.as_ref().is_some_and(|h| h.hub.chat_filter_on()) =>
|
||||
{
|
||||
if let Some(mut host) = self.lan.take() {
|
||||
let reply = self.unmute(&mut host, rest[0], "Server");
|
||||
let words: Vec<&str> = [command].into_iter().chain(rest).collect();
|
||||
let replies = self.mute_command(&mut host, &words, "Server");
|
||||
self.lan = Some(host);
|
||||
self.console_reply(lit(&reply));
|
||||
for reply in replies {
|
||||
self.console_reply(lit(&reply));
|
||||
}
|
||||
}
|
||||
}
|
||||
"ban" if !rest.is_empty() => {
|
||||
|
||||
@@ -2927,9 +2927,9 @@ impl Game<'_> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The chat filter's `/unmute` (game/chat_filter.rs), on servers that filter.
|
||||
if words[0] == "unmute" && host.hub.chat_filter_on() {
|
||||
self.guest_unmute(host, conn, &words);
|
||||
// The chat filter's mute commands (game/chat_filter.rs), on servers that filter.
|
||||
if super::chat_filter::MUTE_COMMANDS.contains(&words[0]) && host.hub.chat_filter_on() {
|
||||
self.guest_mute_command(host, conn, &words);
|
||||
return;
|
||||
}
|
||||
// A native mod's command, for every player its level allows,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! 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};
|
||||
use horizon_chat_filter::{Action, Guard, Mute, duration_text};
|
||||
use horizon_protocol::{
|
||||
packets::play::{self, Clientbound as C},
|
||||
text::literal,
|
||||
@@ -38,30 +38,45 @@ impl Place {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the sender reads when their text was refused (`hit` false: refused because they are muted).
|
||||
pub fn notice(place: Place, hit: bool, action: Action, now: i64) -> String {
|
||||
let minutes = |until: i64| (until - now + 59_999) / 60_000;
|
||||
/// What the sender reads when their text held a slur.
|
||||
pub fn notice(place: Place, action: Action, now: i64) -> String {
|
||||
let refused = match place {
|
||||
Place::Chat | Place::Command => "That message wasn't sent",
|
||||
Place::Sign => "That sign wasn't changed",
|
||||
Place::Anvil => "That name wasn't used",
|
||||
Place::Book => "That book wasn't signed",
|
||||
};
|
||||
match (hit, action) {
|
||||
(false, Action::Muted { until }) => {
|
||||
format!(
|
||||
"You're muted for {} more minutes: slurs aren't allowed here.",
|
||||
minutes(until)
|
||||
)
|
||||
}
|
||||
(_, Action::Muted { until }) => format!(
|
||||
"{refused}: slurs aren't allowed here. You're muted for {} minutes.",
|
||||
minutes(until)
|
||||
match action {
|
||||
Action::Muted { until } => format!(
|
||||
"{refused}: slurs aren't allowed here. You're muted for {}.",
|
||||
duration_text(until - now)
|
||||
),
|
||||
_ => format!("{refused}: slurs aren't allowed here."),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a muted player reads: when they try to chat, and when an operator mutes them.
|
||||
pub fn muted_notice(mute: &Mute, now: i64) -> String {
|
||||
let time = match mute.until {
|
||||
// "2 more minutes".
|
||||
Some(until) => format!(
|
||||
"for {}",
|
||||
duration_text(until - now).replacen(' ', " more ", 1)
|
||||
),
|
||||
None => "until an operator unmutes you".into(),
|
||||
};
|
||||
let why = match (&mute.by, &mute.reason) {
|
||||
(None, _) => "slurs aren't allowed here",
|
||||
(Some(_), Some(reason)) => reason,
|
||||
(Some(_), None) => "",
|
||||
};
|
||||
if why.is_empty() {
|
||||
format!("You're muted {time}.")
|
||||
} else {
|
||||
format!("You're muted {time}: {why}.")
|
||||
}
|
||||
}
|
||||
|
||||
/// The text in red.
|
||||
fn red(text: &str) -> Tag {
|
||||
let mut t = literal(text);
|
||||
@@ -98,15 +113,12 @@ impl Hub {
|
||||
};
|
||||
let (uuid, now) = (self.guests[i].uuid, now_millis());
|
||||
let hit = texts.iter().any(|t| guard.filter.matches(t));
|
||||
let muted = place
|
||||
.is_chat()
|
||||
.then(|| guard.muted_until(uuid, now))
|
||||
.flatten();
|
||||
let muted = place.is_chat().then(|| guard.muted(uuid, now)).flatten();
|
||||
if !hit {
|
||||
let Some(until) = muted else {
|
||||
let Some(mute) = muted else {
|
||||
return true;
|
||||
};
|
||||
let text = notice(place, false, Action::Muted { until }, now);
|
||||
let text = muted_notice(mute, now);
|
||||
self.guests[i].send(&C::SystemChat(play::SystemChat {
|
||||
content: red(&text),
|
||||
overlay: false,
|
||||
@@ -116,7 +128,7 @@ impl Hub {
|
||||
// An anvil renames at every key typed: refused quietly, on the action bar, with no hit.
|
||||
if place == Place::Anvil {
|
||||
self.guests[i].send(&C::SystemChat(play::SystemChat {
|
||||
content: red(¬ice(place, true, Action::Refused, now)),
|
||||
content: red(¬ice(place, Action::Refused, now)),
|
||||
overlay: true,
|
||||
}));
|
||||
return false;
|
||||
@@ -125,7 +137,7 @@ impl Hub {
|
||||
let (count, action) = guard.strike(uuid, &name, now);
|
||||
if !matches!(action, Action::Banned { .. }) {
|
||||
self.guests[i].send(&C::SystemChat(play::SystemChat {
|
||||
content: red(¬ice(place, true, action, now)),
|
||||
content: red(¬ice(place, action, now)),
|
||||
overlay: false,
|
||||
}));
|
||||
}
|
||||
@@ -140,6 +152,38 @@ impl Hub {
|
||||
false
|
||||
}
|
||||
|
||||
/// An operator's `/mute` or `/tempmute`; the player is told if they are here.
|
||||
pub fn mute(
|
||||
&mut self,
|
||||
uuid: u128,
|
||||
name: &str,
|
||||
until: Option<i64>,
|
||||
by: &str,
|
||||
reason: Option<String>,
|
||||
) {
|
||||
let Some(guard) = self.chat_filter.as_deref_mut() else {
|
||||
return;
|
||||
};
|
||||
let now = now_millis();
|
||||
guard.mute(uuid, name, until, by, reason);
|
||||
let Some(text) = guard.muted(uuid, now).map(|m| muted_notice(m, now)) else {
|
||||
return;
|
||||
};
|
||||
for g in self.guests.iter().filter(|g| g.uuid == uuid) {
|
||||
g.send(&C::SystemChat(play::SystemChat {
|
||||
content: red(&text),
|
||||
overlay: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// The mutes in force, by name.
|
||||
pub fn mutes(&self) -> Vec<Mute> {
|
||||
self.chat_filter.as_deref().map_or_else(Vec::new, |g| {
|
||||
g.mutes(now_millis()).into_iter().cloned().collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// `/unmute <player>`: the name as it was muted, if they were.
|
||||
pub fn unmute(&mut self, name: &str) -> Option<String> {
|
||||
self.chat_filter.as_deref_mut()?.unmute(name)
|
||||
@@ -162,13 +206,12 @@ mod tests {
|
||||
fn the_sender_is_told_why() {
|
||||
let now = 1_000_000;
|
||||
assert_eq!(
|
||||
notice(Place::Chat, true, Action::Refused, now),
|
||||
notice(Place::Chat, Action::Refused, now),
|
||||
"That message wasn't sent: slurs aren't allowed here."
|
||||
);
|
||||
assert_eq!(
|
||||
notice(
|
||||
Place::Command,
|
||||
true,
|
||||
Action::Muted {
|
||||
until: now + 600_000
|
||||
},
|
||||
@@ -176,19 +219,27 @@ mod tests {
|
||||
),
|
||||
"That message wasn't sent: slurs aren't allowed here. You're muted for 10 minutes."
|
||||
);
|
||||
let mute = |until, by: Option<&str>, reason: Option<&str>| Mute {
|
||||
uuid: String::new(),
|
||||
name: "Ann".into(),
|
||||
until,
|
||||
by: by.map(Into::into),
|
||||
reason: reason.map(Into::into),
|
||||
};
|
||||
assert_eq!(
|
||||
notice(
|
||||
Place::Chat,
|
||||
false,
|
||||
Action::Muted {
|
||||
until: now + 61_000
|
||||
},
|
||||
now
|
||||
),
|
||||
muted_notice(&mute(Some(now + 61_000), None, None), now),
|
||||
"You're muted for 2 more minutes: slurs aren't allowed here."
|
||||
);
|
||||
assert_eq!(
|
||||
notice(Place::Sign, true, Action::Refused, now),
|
||||
muted_notice(&mute(None, Some("Op"), Some("spam")), now),
|
||||
"You're muted until an operator unmutes you: spam."
|
||||
);
|
||||
assert_eq!(
|
||||
muted_notice(&mute(Some(now + 7_200_000), Some("Op"), None), now),
|
||||
"You're muted for 2 more hours."
|
||||
);
|
||||
assert_eq!(
|
||||
notice(Place::Sign, Action::Refused, now),
|
||||
"That sign wasn't changed: slurs aren't allowed here."
|
||||
);
|
||||
assert!(Place::Chat.is_chat() && Place::Command.is_chat() && !Place::Sign.is_chat());
|
||||
|
||||
+15
-2
@@ -18,7 +18,20 @@ tunes it. LAN worlds and single player don't filter.
|
||||
muted doesn't count towards the window, so the hit after a mute still counts as the next. Anvil names
|
||||
never count (the client renames at every key typed): they are only refused.
|
||||
- Mutes are kept in `chat-filter-mutes.json` beside `server.properties`, and survive a restart.
|
||||
Operators of level 3 lift one with `/unmute <player>`; the console takes `unmute <player>`.
|
||||
|
||||
## Muting by hand
|
||||
|
||||
Operators of level 3 (`/ban`'s level) mute players themselves; the console takes the same words without the
|
||||
slash. These are Horizon extras, and exist only on servers that filter chat.
|
||||
|
||||
- `/mute <player> [reason]`: muted until `/unmute`.
|
||||
- `/tempmute <player> <duration> [reason]`: muted for a duration such as `30s`, `10m`, `2h`, `1d` or `1w`.
|
||||
- `/mutelist`: every mute in force, the filter's and the operators', with who muted, until when and why.
|
||||
- `/unmute <player>`: lifts either kind.
|
||||
|
||||
A player muted by hand is told at once, then gets the same refusal as the filter's mutes in chat and message
|
||||
commands, with the operator's reason. The player may be offline if the server has seen them. A filter mute
|
||||
never shortens a longer mute an operator gave.
|
||||
|
||||
## Settings
|
||||
|
||||
@@ -51,4 +64,4 @@ in an entry must be doubled in the text, so Niger and Nigeria pass, and so do "s
|
||||
Keep the list to slurs: insults that aren't slurs are the moderators' to handle.
|
||||
|
||||
Code: `crates/horizon-chat-filter` (settings, matcher, escalation, mutes), `lan/hub/chat_filter.rs` (the
|
||||
check before the server relays a text), `game/chat_filter.rs` (log, events, bans, `/unmute`).
|
||||
check before the server relays a text), `game/chat_filter.rs` (log, events, bans, the mute commands).
|
||||
@@ -12,7 +12,8 @@ not run; then reads the settings and changes the difficulty live, and backs up,
|
||||
and runs the same moderation from the `horizon-server` CLI.
|
||||
The API listens on the address and with the token server.toml's [api] fixes, as a moderation bot's would.
|
||||
An event stream (server-sent events) opened before the bots join must see them join, Kim0's chat (sent as a raw
|
||||
chat packet) and the chat filter's refusal of the next one (`chat_filtered`, no `chat`), Lou0's death, the kick and the leaves, typed, with the same events polled as JSON. Last,
|
||||
chat packet) and the chat filter's refusal of the next one (`chat_filtered`, no `chat`), then the console mutes,
|
||||
lists and unmutes them by hand (`mute`, `tempmute`, `mutelist`, `unmute`), Lou0's death, the kick and the leaves, typed, with the same events polled as JSON. Last,
|
||||
`horizon-server mcp` bans and pardons, reads events and refuses an unconfirmed stop, and `horizon-server
|
||||
console` attaches through a pipe, runs two commands and shows what they log.
|
||||
|
||||
@@ -160,6 +161,23 @@ def scenario(bins, folder, server, port):
|
||||
check(log.exists() and 'Kim0' in log.read_text() and FILTERED in log.read_text(),
|
||||
'logs/chat-filter.log keeps the refused text')
|
||||
server.wait_for(r"\[chat-filter\] Refused Kim0's chat \(hit 1: refused\)", 30)
|
||||
|
||||
# An operator's mutes, from the console, kept with the filter's in chat-filter-mutes.json.
|
||||
def command(line, expect):
|
||||
out = api('POST', '/api/v1/command', {'line': line})['output']
|
||||
check(any(expect in o for o in out), f'{line}: "{expect}" ({out})')
|
||||
command('mute Lou0 spamming', 'Muted Lou0 until unmuted: spamming')
|
||||
command('tempmute Kim0 2h', 'Muted Kim0 for 2 hours')
|
||||
command('tempmute Kim0 soon', "soon isn't a duration: 10m, 2h, 1d")
|
||||
command('mutelist', 'There are 2 mute(s):')
|
||||
command('mutelist', 'Lou0 was muted by Server until unmuted: spamming')
|
||||
command('mutelist', 'Kim0 was muted by Server for 2 more hours')
|
||||
mutes = json.loads((folder / 'chat-filter-mutes.json').read_text())['mutes']
|
||||
check([(m['name'], m['by'], m['until'] is None) for m in mutes] == [('Kim0', 'Server', False), ('Lou0', 'Server', True)],
|
||||
f'chat-filter-mutes.json has both mutes ({mutes})')
|
||||
command('unmute Lou0', 'Unmuted Lou0')
|
||||
command('unmute Kim0', 'Unmuted Kim0')
|
||||
command('mutelist', 'There are no mutes')
|
||||
players = api('GET', '/api/v1/players')
|
||||
check(players['max_players'] == 20 and players['ops'] == [] and players['bans'] == [],
|
||||
f'players: 20 slots, no operators, no bans ({players})')
|
||||
|
||||
Reference in new issue
Block a user