How to make a Rust chat anti-spam plugin (Oxide/Carbon)
Blocks chat messages that exceed a rate limit or duplicate the player's previous message, and mutes repeat offenders for a configurable time. It uses the OnPlayerChat hook and returns a non-null object to cancel a message.
Public Rust servers get hit with chat spam: the same ad pasted repeatedly, rapid-fire messages, or bots flooding the chat with links. Rust itself has no rate limit on chat, so a single player can push dozens of messages in a second. The fix is a plugin that inspects every outgoing chat message, tracks per-player history, and cancels anything that breaks your rules.
This page shows a complete Oxide plugin built around the OnPlayerChat hook. For each message it records a timestamp, counts how many messages the player sent inside a sliding window, and compares the text with the previous one. A message over the limit or identical to the last one is cancelled, and the plugin warns the player. If the player trips the rule repeatedly inside the configured window, the plugin mutes them for a set number of seconds. All timings, limits and texts live in the config and language files.
The plugin code
using UnityEngine;
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
namespace Oxide.Plugins
{
[Info("Chat Anti-Spam", "RustPluginGenerator", "1.0.0")]
[Description("Blocks chat spam by rate limit and duplicate messages, and mutes repeat offenders.")]
public class ChatAntiSpam : RustPlugin
{
private const string PermUse = "chatantispam.use";
private const string PermAdmin = "chatantispam.admin";
private const string PermBypass = "chatantispam.bypass";
private PluginConfig config;
private readonly Dictionary<ulong, PlayerData> data = new Dictionary<ulong, PlayerData>();
private readonly Dictionary<ulong, Timer> muteTimers = new Dictionary<ulong, Timer>();
private class PluginConfig
{
[JsonProperty("Maximum messages per window")]
public int MaxMessages { get; set; } = 5;
[JsonProperty("Window length in seconds")]
public int WindowSeconds { get; set; } = 10;
[JsonProperty("Block duplicate messages")]
public bool BlockDuplicates { get; set; } = true;
[JsonProperty("Violations before mute")]
public int ViolationsBeforeMute { get; set; } = 3;
[JsonProperty("Violation window in seconds")]
public int ViolationWindowSeconds { get; set; } = 60;
[JsonProperty("Mute duration in seconds")]
public int MuteSeconds { get; set; } = 300;
[JsonProperty("Ignore admins")]
public bool IgnoreAdmins { get; set; } = true;
}
private class PlayerData
{
public List<double> Timestamps = new List<double>();
public string LastMessage = string.Empty;
public int Violations;
public double FirstViolation;
public DateTime MutedUntil = DateTime.MinValue;
}
protected override void LoadDefaultConfig() => config = new PluginConfig();
protected override void LoadConfig()
{
base.LoadConfig();
try
{
config = Config.ReadObject<PluginConfig>();
if (config == null) throw new Exception();
}
catch
{
PrintWarning("Configuration file is invalid, using defaults");
LoadDefaultConfig();
}
SaveConfig();
}
protected override void SaveConfig() => Config.WriteObject(config);
protected override void LoadDefaultMessages()
{
lang.RegisterMessages(new Dictionary<string, string>
{
["RateLimited"] = "Slow down, you are sending messages too fast.",
["DuplicateMessage"] = "You already sent that message.",
["Muted"] = "You are muted in chat for {0} seconds for spamming.",
["StillMuted"] = "You are muted for another {0} seconds.",
["Unmuted"] = "Your chat mute has expired.",
["NoPermission"] = "You do not have permission to use that command.",
["StatusMuted"] = "You are muted for another {0} seconds.",
["StatusClear"] = "You are not muted. Messages in window: {0}.",
["ResetDone"] = "Cleared chat history and mutes for {0}.",
["PlayerNotFound"] = "No player found matching that name or id.",
["UsageReset"] = "Usage: /antispam reset <player name or id>"
}, this);
}
private string Msg(string key, string userId = null, params object[] args) =>
string.Format(lang.GetMessage(key, this, userId), args);
private void Init()
{
permission.RegisterPermission(PermUse, this);
permission.RegisterPermission(PermAdmin, this);
permission.RegisterPermission(PermBypass, this);
}
private object OnPlayerChat(BasePlayer player, string message, ConVar.Chat.ChatChannel channel)
{
if (player == null || string.IsNullOrEmpty(message)) return null;
if (player.IsNpc) return null;
if (config.IgnoreAdmins && player.IsAdmin) return null;
var userId = player.UserIDString;
if (permission.UserHasPermission(userId, PermBypass)) return null;
var id = player.userID.Get();
if (!data.TryGetValue(id, out var entry))
{
entry = new PlayerData();
data[id] = entry;
}
if (entry.MutedUntil > DateTime.UtcNow)
{
var left = (int)Math.Ceiling((entry.MutedUntil - DateTime.UtcNow).TotalSeconds);
player.ChatMessage(Msg("StillMuted", userId, left));
return new object();
}
var now = UnityEngine.Time.realtimeSinceStartup;
// Prune timestamps outside the window
entry.Timestamps.RemoveAll(t => now - t > config.WindowSeconds);
var duplicate = config.BlockDuplicates &&
!string.IsNullOrEmpty(entry.LastMessage) &&
string.Equals(entry.LastMessage.Trim(), message.Trim(), StringComparison.OrdinalIgnoreCase);
var tooFast = entry.Timestamps.Count >= config.MaxMessages;
entry.Timestamps.Add(now);
if (!duplicate && !tooFast)
{
entry.LastMessage = message;
return null;
}
if (duplicate)
player.ChatMessage(Msg("DuplicateMessage", userId));
else
player.ChatMessage(Msg("RateLimited", userId));
entry.LastMessage = message;
RegisterViolation(player, entry, now);
return new object();
}
private void RegisterViolation(BasePlayer player, PlayerData entry, double now)
{
if (now - entry.FirstViolation > config.ViolationWindowSeconds)
{
entry.FirstViolation = now;
entry.Violations = 0;
}
entry.Violations++;
if (entry.Violations < config.ViolationsBeforeMute) return;
entry.Violations = 0;
StartMute(player, entry);
}
private void StartMute(BasePlayer player, PlayerData entry)
{
var id = player.userID.Get();
entry.MutedUntil = DateTime.UtcNow.AddSeconds(config.MuteSeconds);
entry.Timestamps.Clear();
player.ChatMessage(Msg("Muted", player.UserIDString, config.MuteSeconds));
if (muteTimers.TryGetValue(id, out var old) && old != null) old.Destroy();
muteTimers[id] = timer.Once(config.MuteSeconds, () =>
{
muteTimers.Remove(id);
if (data.TryGetValue(id, out var current))
{
current.MutedUntil = DateTime.MinValue;
current.Violations = 0;
}
var online = BasePlayer.FindByID(id);
if (online != null && online.IsConnected)
online.ChatMessage(Msg("Unmuted", online.UserIDString));
});
}
[ChatCommand("antispam")]
private void CmdAntiSpam(BasePlayer player, string command, string[] args)
{
if (player == null) return;
var userId = player.UserIDString;
if (args == null || args.Length == 0)
{
if (!permission.UserHasPermission(userId, PermUse))
{
player.ChatMessage(Msg("NoPermission", userId));
return;
}
ShowStatus(player);
return;
}
switch (args[0].ToLower())
{
case "status":
if (!permission.UserHasPermission(userId, PermUse))
{
player.ChatMessage(Msg("NoPermission", userId));
return;
}
ShowStatus(player);
break;
case "reset":
if (!permission.UserHasPermission(userId, PermAdmin))
{
player.ChatMessage(Msg("NoPermission", userId));
return;
}
ResetTarget(player, args);
break;
default:
player.ChatMessage(Msg("UsageReset", userId));
break;
}
}
private void ShowStatus(BasePlayer player)
{
var userId = player.UserIDString;
var id = player.userID.Get();
if (!data.TryGetValue(id, out var entry))
{
player.ChatMessage(Msg("StatusClear", userId, 0));
return;
}
if (entry.MutedUntil > DateTime.UtcNow)
{
var left = (int)Math.Ceiling((entry.MutedUntil - DateTime.UtcNow).TotalSeconds);
player.ChatMessage(Msg("StatusMuted", userId, left));
return;
}
var now = UnityEngine.Time.realtimeSinceStartup;
entry.Timestamps.RemoveAll(t => now - t > config.WindowSeconds);
player.ChatMessage(Msg("StatusClear", userId, entry.Timestamps.Count));
}
private void ResetTarget(BasePlayer admin, string[] args)
{
if (args.Length < 2)
{
admin.ChatMessage(Msg("UsageReset", admin.UserIDString));
return;
}
var query = string.Join(" ", args);
query = query.Substring(query.IndexOf(' ') + 1).Trim();
var target = BasePlayer.FindAwakeOrSleeping(query);
if (target == null)
{
admin.ChatMessage(Msg("PlayerNotFound", admin.UserIDString));
return;
}
var id = target.userID.Get();
data.Remove(id);
if (muteTimers.TryGetValue(id, out var t) && t != null) t.Destroy();
muteTimers.Remove(id);
admin.ChatMessage(Msg("ResetDone", admin.UserIDString, target.displayName));
}
private void Unload()
{
foreach (var t in muteTimers.Values)
t?.Destroy();
muteTimers.Clear();
data.Clear();
}
}
}Hooks used: OnPlayerChat
Config file
Created automatically on first load at oxide/config/ChatAntiSpam.json.
{
"Maximum messages per window": 5,
"Window length in seconds": 10,
"Block duplicate messages": true,
"Violations before mute": 3,
"Violation window in seconds": 60,
"Mute duration in seconds": 300,
"Ignore admins": true
}Commands and permissions
| Command | What it does | Permission |
|---|---|---|
/antispam status | Shows your own mute state and how many messages you sent in the current window. | chatantispam.use |
/antispam reset <player name or id> | Clears message history and removes an active mute for the target player. | chatantispam.admin |
| Permission | Grants |
|---|---|
chatantispam.use | Allows the player to run /antispam status. |
chatantispam.admin | Allows /antispam reset to clear history and mutes. |
chatantispam.bypass | Skips all anti-spam checks, useful for staff and moderators. |
How the code works
Cancel a chat message from OnPlayerChat
object OnPlayerChat(BasePlayer player, string message, Chat.ChatChannel channel)fires for every chat message before it reaches other players. Returningnulllets the message through; returning any non-null object cancels it. The plugin returnsnew object()only when a message must be blocked, and always returnsnullotherwise. Note that admin and console-relayed messages pass through the same hook, so check thechannelparameter if you only want to police global chat.Track history in a dictionary
A
Dictionary<ulong, PlayerData>keyed onplayer.userID.Get()stores the last message text, the timestamps of recent messages, a violation counter and the mute expiry. On each call the plugin callsRemoveAllon the timestamp list to prune entries older thanWindowSeconds.PlayerDatais a nested class with public fields:Timestamps,LastMessage,Violations,FirstViolationandMutedUntil. A second dictionary,muteTimers, holds the per-player muteTimerreferences soUnloadcan destroy them.Rate limit and duplicate detection
After pruning, if the number of remaining timestamps is already at
MaxMessages, the message is blocked with theRateLimitedlang key. Separately, ifBlockDuplicatesis true and the text equals the storedLastMessage(compared case-insensitively after trimming), it is blocked withDuplicateMessage. Both paths record a violation and may trigger a mute.chatantispam.bypassholders are skipped entirely, and whenIgnoreAdminsis true so areplayer.IsAdminplayers; NPCs always pass.Mute repeat offenders
Each block increments
Violations.RegisterViolationresets the counter when more thanViolationWindowSecondshave passed sinceFirstViolation, and onceViolationsreachesViolationsBeforeMuteit callsStartMute. That setsMutedUntiltoDateTime.UtcNowplusMuteSeconds, clears the timestamps, sends theMutedmessage and stores a singletimer.OnceinmuteTimers. WhileMutedUntilis in the future every incoming message is cancelled withStillMutedand the remaining seconds; the timer later sendsUnmuted.Keep configuration and messages outside the code
LoadDefaultConfigseeds aPluginConfigwith every number the plugin needs:MaxMessages,WindowSeconds,BlockDuplicates,ViolationsBeforeMute,ViolationWindowSeconds,MuteSecondsandIgnoreAdmins.LoadDefaultMessagesregisters all player-facing text (RateLimited,DuplicateMessage,Muted,StillMuted,Unmuted,NoPermission,StatusMuted,StatusClear,ResetDone,PlayerNotFound,UsageReset) so admins can reword or translate them without touching C#. The customLoadConfigfalls back to defaults and prints one warning if the file cannot be read.Status and admin reset commands
/antispamwith no argument or/antispam status(permissionchatantispam.use) prints the caller's mute state and how many messages are counted in the current window viaShowStatus./antispam reset <name or id>(permissionchatantispam.admin) resolves the target withBasePlayer.FindAwakeOrSleeping, removes the dictionary entry, destroys any mute timer inmuteTimersand confirms with theResetDonekey. Both are registered with[ChatCommand("antispam")]and checkpermission.UserHasPermissionbefore acting.Clean up on unload and reload
UnloaditeratesmuteTimers, callsDestroy()on every storedTimer, and clears both dictionaries so a reload starts clean. There is no UI to remove and no data file to write, because the plugin keeps state in memory only. Reloading the plugin wipes every mute and message history. If you later want mutes to survive restarts you would have to persist thedatadictionary yourself; the shipped version intentionally does not.
Install it on your server
Save ChatAntiSpam.cs
Keep the file name exactly as the class name. Oxide matches them.Upload it to the plugins folder
Oxide:oxide/plugins/. Carbon:carbon/plugins/. The server compiles and loads it within a few seconds, no restart needed.Edit the config if you want
On first load the default config is written tooxide/config/ChatAntiSpam.json(Carbon:carbon/configs/ChatAntiSpam.json). After editing, reload it:oxide.reload ChatAntiSpam c.reload ChatAntiSpam # CarbonGrant the permission
Give it to every player, or swapdefaultfor a group such asvip:oxide.grant group default chatantispam.use c.grant group default chatantispam.use # Carbon
Variations
Each one opens the generator with a ready prompt you can tweak.
Block links and advertising words
Adds a keyword and URL filter on top of the rate limit.
Generate this versionEscalating mute durations
Increases mute length for players muted multiple times.
Generate this versionDiscord webhook logging
Logs blocked messages to a Discord channel.
Generate this version
FAQ
Does OnPlayerChat see private messages between players?
No. Server-wide chat arrives through OnPlayerChat with the Chat.ChatChannel.Global channel. Private messages use a separate path and do not pass through this hook, so this plugin only polices global chat. If you need to moderate team chat, that is a different hook and a different design.
Why does returning an object cancel the message?
Oxide treats any non-null return from OnPlayerChat as a block; the object's value is not used. The convention is to return false or a string, but any non-null value works. This plugin returns new object() purely for clarity and always returns null when the message should go through.
Is the mute a real Rust mute?
No. The plugin cancels messages while MutedUntil is in the future and shows the remaining seconds, but the player keeps their voice and can still type into the chat box. Nothing is sent to the client. If you need the built-in mute, call player.SendConsoleCommand with the mute command from a timer, but the cancel approach here is simpler and reversible from config.
How do I install it on Oxide or Carbon?
Save the file as ChatAntiSpam.cs and drop it in oxide/plugins. Oxide compiles and loads it; Carbon loads the same file from its plugins folder. Edit oxide/config/ChatAntiSpam.json to change limits, and oxide/lang/en/ChatAntiSpam.json to reword messages. Reload with oxide.reload ChatAntiSpam after editing config.
What happens if a player is muted and disconnects?
The in-memory entry is kept until the mute timer fires or the plugin is unloaded, but BasePlayer.FindByID returns null while they are offline, so no message is sent. The mute expiry is stored as a DateTime, so on reconnect the player stays muted until the original expiry time passes, or until an admin runs /antispam reset.
Customize it for your server
Change the reward, add a cooldown, restrict it to VIPs. Start from this prompt and edit anything.
Edit the prompt on the next screen. 2 free plugins a month, no card.
More moderation plugins
- AFK kickTracks each connected player's movement and look direction with a repeating timer and kicks anyone who stays idle past a configured limit after aโฆOnServerInitializedOnPlayerConnected
- Chat muteA timed chat mute plugin: /mute <player> <minutes> [reason] stores a mute with an expiry in a data file, /unmute <player> clears it, andโฆOnServerInitializedOnPlayerChat
- Chat word filterThis plugin replaces blocked words in player chat with asterisks using case-insensitive whole-word matching and re-broadcasts the cleaned line underโฆOnServerInitializedOnPlayerChat
Last updated Oct 26, 2026.
Customize chat anti-spam
Customize