Skip to content
RustPluginGenerator

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

ChatAntiSpam.cs
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.

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

Chat and console commands
CommandWhat it doesPermission
/antispam statusShows 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
Permissions
PermissionGrants
chatantispam.useAllows the player to run /antispam status.
chatantispam.adminAllows /antispam reset to clear history and mutes.
chatantispam.bypassSkips all anti-spam checks, useful for staff and moderators.

How the code works

  1. 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. Returning null lets the message through; returning any non-null object cancels it. The plugin returns new object() only when a message must be blocked, and always returns null otherwise. Note that admin and console-relayed messages pass through the same hook, so check the channel parameter if you only want to police global chat.

  2. Track history in a dictionary

    A Dictionary<ulong, PlayerData> keyed on player.userID.Get() stores the last message text, the timestamps of recent messages, a violation counter and the mute expiry. On each call the plugin calls RemoveAll on the timestamp list to prune entries older than WindowSeconds. PlayerData is a nested class with public fields: Timestamps, LastMessage, Violations, FirstViolation and MutedUntil. A second dictionary, muteTimers, holds the per-player mute Timer references so Unload can destroy them.

  3. Rate limit and duplicate detection

    After pruning, if the number of remaining timestamps is already at MaxMessages, the message is blocked with the RateLimited lang key. Separately, if BlockDuplicates is true and the text equals the stored LastMessage (compared case-insensitively after trimming), it is blocked with DuplicateMessage. Both paths record a violation and may trigger a mute. chatantispam.bypass holders are skipped entirely, and when IgnoreAdmins is true so are player.IsAdmin players; NPCs always pass.

  4. Mute repeat offenders

    Each block increments Violations. RegisterViolation resets the counter when more than ViolationWindowSeconds have passed since FirstViolation, and once Violations reaches ViolationsBeforeMute it calls StartMute. That sets MutedUntil to DateTime.UtcNow plus MuteSeconds, clears the timestamps, sends the Muted message and stores a single timer.Once in muteTimers. While MutedUntil is in the future every incoming message is cancelled with StillMuted and the remaining seconds; the timer later sends Unmuted.

  5. Keep configuration and messages outside the code

    LoadDefaultConfig seeds a PluginConfig with every number the plugin needs: MaxMessages, WindowSeconds, BlockDuplicates, ViolationsBeforeMute, ViolationWindowSeconds, MuteSeconds and IgnoreAdmins. LoadDefaultMessages registers 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 custom LoadConfig falls back to defaults and prints one warning if the file cannot be read.

  6. Status and admin reset commands

    /antispam with no argument or /antispam status (permission chatantispam.use) prints the caller's mute state and how many messages are counted in the current window via ShowStatus. /antispam reset <name or id> (permission chatantispam.admin) resolves the target with BasePlayer.FindAwakeOrSleeping, removes the dictionary entry, destroys any mute timer in muteTimers and confirms with the ResetDone key. Both are registered with [ChatCommand("antispam")] and check permission.UserHasPermission before acting.

  7. Clean up on unload and reload

    Unload iterates muteTimers, calls Destroy() on every stored Timer, 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 the data dictionary yourself; the shipped version intentionally does not.

Install it on your server

  1. Save ChatAntiSpam.cs

    Keep the file name exactly as the class name. Oxide matches them.
  2. 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.
  3. Edit the config if you want

    On first load the default config is written to oxide/config/ChatAntiSpam.json (Carbon: carbon/configs/ChatAntiSpam.json). After editing, reload it:oxide.reload ChatAntiSpam c.reload ChatAntiSpam # Carbon
  4. Grant the permission

    Give it to every player, or swap default for a group such as vip: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.

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.

Prompt: Create a Rust Oxide plugin named ChatAntiSpam that blocks chat when a player sends more than 5 messages in 10 seconds or repeats the previous message, and mutes repeat offenders for 300 seconds after 3 violations in 60 seconds.
Customize for my server

Edit the prompt on the next screen. 2 free plugins a month, no card.

Last updated Oct 26, 2026.

Customize chat anti-spam

Customize