Server: /mute, /tempmute and /mutelist for ops

This commit is contained in:
Simon Dräger committed 2026-11-01 22:07:56 -08:00
1 parent 3a264943c7
commit 4d0c0fb6d1
8 files changed
+403 -85

No files matched your search

+92 -18
View File
@@ -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)
+74 -6
View File
@@ -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 {
+108 -18
View File
@@ -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.
+8 -4
View File
@@ -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(&notice(place, true, Action::Refused, now)),
content: red(&notice(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(&notice(place, true, action, now)),
content: red(&notice(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
View File
@@ -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).
+19 -1
View File
@@ -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})')