Skip to content

Intercept chat: cancel and rewrite

Changing. Follow: #283, #636

This tutorial shows how a plugin cancels a chat line or replaces its text. Chat is the first public player event of mcrs and, for now, the only one. It is the first merged part of the planned public, cancellable player events, and events for join, leave, movement and blocks do not exist yet.12 The examples come from test files of the repository, and we did not run them outside those tests.

The server raises PlayerChatEvent for a chat line before it broadcasts the line.

PlayerChatEvent
#[derive(Event, Debug)]
pub struct PlayerChatEvent {
pub player: Entity,
pub text: Text,
pub cancelled: bool,
}

crates/mcrs_minecraft_chat_server/src/chat_plugin.rs:19-24

Field Meaning
player the entity of the sender, in the world of the sender’s dimension
text the line as a Text, not a String; change it to rewrite the line
cancelled set it to true and the line is not broadcast

Text is the styled text type of the protocol, so a line can carry colors and components. To make a Text from a string, use into_text(), as the tests do.

The server checks a line first and raises the event only when the line passes, so the event comes after the checks and before the broadcast.

Line What the server does
Has an illegal character Raises no event and logs that it dropped the line
Comes from a player who hides chat Raises no event and sends the player the “chat disabled” message
Any other line Raises the event in the dimension world of the sender. After the observers ran, it reads cancelled and text and broadcasts
Raise the event, then broadcast
let text = msg.to_string().into_text();
let mut chat_event = PlayerChatEvent {
player: event.entity,
text,
cancelled: false,
};
let username = profile.username.clone();
commands.queue(move |world: &mut World| {
world.trigger_ref(&mut chat_event);
if chat_event.cancelled {
return;
}
info!("<{}> {:?}", username, chat_event.text);
let text: Text = Text::translate(
"chat.type.text",
vec![username.clone().into_text(), chat_event.text],
);
let text = match RawText::from_text(&text) {
Ok(text) => text,
Err(err) => {
warn!(player = %username, "dropping a chat line that does not encode: {err:?}");
return;
}
};
world.write_message(to_target(PacketTarget::AllPlayers, system_chat(text)));
})

crates/mcrs_minecraft_chat_server/src/chat_plugin.rs:64-93

The trigger runs in a queued command. Because of that, the broadcast happens right after handle_chat ends and not inside it.

In Paper you call setCancelled(true) on AsyncChatEvent. Here you write an observer, which is a function with the parameter On<PlayerChatEvent>, and set the field in it.

Test: an observer cancels a line
#[test]
fn an_observer_that_cancels_a_chat_line_stops_its_broadcast() {
fn cancel(mut event: On<PlayerChatEvent>) {
event.cancelled = true;
}
let mut app = host_app::make_host_app();
app.add_dimension_plugin(ChatPlugin);
let mut server = PlayServer::start(app, &[], 0);
let (sender, _) = server.join(Uuid::from_u128(1), "Sender");
let dim = server.dim;
server.world_in(dim).add_observer(cancel);
server.queue(sender, &line("hello"));
let sent = server.ticks_all(2);
assert!(!sent.iter().any(|packet| {
matches!(packet.target, PacketTarget::AllPlayers)
&& matches!(packet.data, PacketPayload::SystemChat(_))
}));
}

crates/mcrs_minecraft_server/tests/it/chat.rs:123-142

Set event.text to a new Text, and the broadcast uses it. The test below also reads event.player to show who spoke.

Test: an observer rewrites a line to ***
#[test]
fn an_observer_that_replaces_a_chat_line_changes_its_broadcast() {
#[derive(Resource)]
struct Speaker(Entity);
fn censor(mut event: On<PlayerChatEvent>, mut commands: Commands) {
commands.insert_resource(Speaker(event.player));
event.text = "***".to_owned().into_text();
}
let mut app = host_app::make_host_app();
app.add_dimension_plugin(ChatPlugin);
let mut server = PlayServer::start(app, &[], 0);
let (sender, _) = server.join(Uuid::from_u128(1), "Sender");
server.world().add_observer(censor);
server.queue(sender, &line("hello"));
let heard: Vec<Text> = server
.ticks_all(2)
.iter()
.filter(|packet| matches!(packet.target, PacketTarget::AllPlayers))
.filter_map(|packet| match &packet.data {
PacketPayload::SystemChat(chat) => {
Some(chat.content.to_text().unwrap().map(&ItemHoverMap).unwrap())
}
_ => None,
})
.collect();
let expected = Text::translate(
"chat.type.text",
vec![
"Sender".to_owned().into_text(),
"***".to_owned().into_text(),
],
);
assert_eq!(heard, [expected]);
let player = server.player(sender);
assert_eq!(server.world().resource::<Speaker>().0, player);
}

crates/mcrs_minecraft_server/tests/it/chat.rs:144-181

Chat runs inside each dimension world, so the observer has to be in the dimension world as well. The tests add it to the world directly with server.world().add_observer(...). In a real server, put app.add_observer(...) into the build method of a Clone plugin and register the plugin with add_dimension_plugin. ChatPlugin itself does the same for its own observer:

ChatPlugin adds its observer in build
impl Plugin for ChatPlugin {
fn build(&self, app: &mut App) {
if !app.is_plugin_added::<ClientInfoPlugin>() {
app.add_plugins(ClientInfoPlugin);
}
app.add_observer(handle_chat);
}
}

crates/mcrs_minecraft_chat_server/src/chat_plugin.rs:26-33

Your first plugin explains why add_plugins does not work here.

The command that runs the repository tests is cargo test -p mcrs_minecraft_server --test it chat::.2 To write your own test, see Test a plugin without a client.

Next: Add a command covers CommandEvent, the event of commands with the same cancelled field. Paper to mcrs has the table of other Paper events and what mcrs has for them.

  1. Partial#283 Tracks public player events that a plugin can cancel. ↩ ↩2

  2. Works#636 Raises a cancellable event for a chat line before its broadcast. ↩ ↩2 ↩3

Sources and verification

Verified: 26.4-snapshot-7, 51d45c26a, 2026-11-09

Tracking: #283, #636