Chat Translator v3
//#define DEBUG

// Requires: TranslationAPI

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Newtonsoft.Json;
using Oxide.Core;
using Oxide.Core.Libraries.Covalence;
using Oxide.Core.Plugins;
using ProtoBuf;
#if RUST
using Facepunch;
using Facepunch.CardGames;
#endif

namespace Oxide.Plugins
{
    [Info("Chat Translator", "Mr. Blue", "3.5.1")]
    [Description("Translates chat messages to each player's language preference or server default")]
    public class ChatTranslator : CovalencePlugin
    {
        #region Configuration

        private Configuration config;

        private class Configuration
        {
            [JsonProperty("Force default server language")]
            public bool ForceServerDefault = false;

            [JsonProperty("Log translated chat messages")]
            public bool LogChatMessages = false;

            [JsonProperty("Show original and translation")]
            public bool ShowBothMessages = false;

            [JsonProperty("Translate message for sender")]
            public bool TranslateForSender = false;

            [JsonProperty("Auto-detect sender language")]
            public bool AutoDetectSenderLanguage = true;

            public string ToJson() => JsonConvert.SerializeObject(this);

            public Dictionary<string, object> ToDictionary() => JsonConvert.DeserializeObject<Dictionary<string, object>>(ToJson());
        }

        protected override void LoadDefaultConfig() => config = new Configuration();

        protected override void LoadConfig()
        {
            base.LoadConfig();
            try
            {
                config = Config.ReadObject<Configuration>();
                if (config == null)
                {
                    throw new JsonException();
                }

                if (!config.ToDictionary().Keys.SequenceEqual(Config.ToDictionary(x => x.Key, x => x.Value).Keys))
                {
                    LogWarning("Configuration appears to be outdated; updating and saving");
                    SaveConfig();
                }
            }
            catch
            {
                LogWarning($"Configuration file {Name}.json is invalid; using defaults");
                LoadDefaultConfig();
            }
        }

        protected override void SaveConfig()
        {
            LogWarning($"Configuration changes saved to {Name}.json");
            Config.WriteObject(config, true);
        }

        #endregion Configuration

        #region Chat Translation

        [PluginReference]
        private readonly Plugin TranslationAPI, BetterChat, BetterChatFilter, BetterChatMute, ChatFilter;

        private void Translate(string message, string targetId, string senderId, Action<string> callback)
        {
            // Get language code to translate to
            string langTo = "en";
            if (!message.Equals("Translation") && config.ForceServerDefault && CultureInfo.GetCultureInfo(lang.GetServerLanguage()) != null)
            {
                langTo = lang.GetServerLanguage();
            }
            else
            {
                langTo = lang.GetLanguage(targetId);
            }

            // Get language code to translate from
            string langFrom = config.AutoDetectSenderLanguage ? "auto" : (lang.GetLanguage(senderId) ?? "auto");

#if DEBUG
            LogWarning($"To ({targetId}): {langTo}, From ({senderId}): {langFrom}");
#endif
            // Translate the message
            TranslationAPI.Call("Translate", message, langTo, langFrom, callback);
        }

        private void TranslateChat(IPlayer target, IPlayer sender, string message, int channel = 0)
        {
            if (sender.Equals(target) && !config.TranslateForSender)
            {
                ProcessMessage(sender, sender, message, message, channel);
                return;
            }

            Action<string> callback = translation =>
            {
                if (config.ShowBothMessages && !message.Equals(translation))
                {
                    Action<string> prefixCallback = prefixResponse =>
                    {
                        translation = $"{message}\n{prefixResponse}: {translation}";
                        ProcessMessage(target, sender, translation, message, channel);
                    };
                    Translate("Translation", target.Id, target.Id, prefixCallback);
                }
                else
                {
                    ProcessMessage(target, sender, translation, message, channel);
                }
            };
            Translate(message, target.Id, sender.Id, callback);
        }

        private void ProcessMessage(IPlayer target, IPlayer sender, string translation, string original, int channel = 0)
        {
            if (target == null || !target.IsConnected)
            {
                return;
            }

            if (Interface.Oxide.CallHook("OnTranslatedChat", sender, translation, original, channel) != null)
            {
                return;
            }

            // Rust colors: Admin/moderator = #aaff55, Developer = #ffaa55, Player = #55aaff
            string prefixColor = sender.IsAdmin ? "#aaff55" : "#55aaff";

            string formatted;
            if (BetterChat != null && BetterChat.IsLoaded)
            {
                formatted = covalence.FormatText(BetterChat.Call<string>("API_GetFormattedMessage", sender, translation));
            }
            else
            {
                formatted = $"{covalence.FormatText($"[{prefixColor}]{sender.Name}[/#]")}: {translation}";
            }

#if RUST
            switch (channel)
            {
                case 1: // Team chat
                    BasePlayer basePlayer = sender.Object as BasePlayer;
                    RelationshipManager.PlayerTeam team = basePlayer.Team;
                    if (team != null && team.members.Count != 0)
                    {
                        // Rust companion app support
                        uint current = (uint)Facepunch.Math.Epoch.Current;
                        ulong targetId = ulong.Parse(target.Id);
                        CompanionServer.Server.TeamChat.Record(team.teamID, targetId, target.Name, translation, prefixColor, current);
                        ProtoBuf.AppBroadcast appBroadcast = Facepunch.Pool.Get<ProtoBuf.AppBroadcast>();
                        appBroadcast.teamMessage = Facepunch.Pool.Get<ProtoBuf.AppNewTeamMessage>();
                        appBroadcast.teamMessage.message = Facepunch.Pool.Get<ProtoBuf.AppTeamMessage>();
                        appBroadcast.ShouldPool = false;
                        AppTeamMessage appChatMessage = appBroadcast.teamMessage.message;
                        appChatMessage.steamId = targetId;
                        appChatMessage.name = target.Name;
                        appChatMessage.message = translation;
                        appChatMessage.color = prefixColor;
                        appChatMessage.time = current;
                        CompanionServer.Server.Broadcast(new CompanionServer.PlayerTarget(targetId), appBroadcast);
                        appBroadcast.ShouldPool = true;
                        appBroadcast.Dispose();
                    }
                    break;
            }
#endif

#if RUST
            target.Command("chat.add", channel, sender.Id, formatted);
#else
            target.Message(formatted);
#endif
        }

        #endregion Chat Translation

        #region Chat Handling

        private object HandleChat(IPlayer sender, string message, int channel = 0, List<string> blockedReceivers = null)
        {
            if (sender == null || string.IsNullOrEmpty(message))
            {
                return null;
            }

            if (ChatFilter != null && ChatFilter.IsLoaded)
            {
                if (ChatFilter.Call<bool>("ContainsAdvertising"))
                {
                    return true; // TODO: Support filtering, not just blocking
                }
            }

            if (BetterChatMute != null && BetterChatMute.IsLoaded)
            {
                if (BetterChatMute.Call<bool>("API_IsMuted", sender))
                {
                    return true;
                }
            }

            if (BetterChatFilter != null && BetterChatFilter.IsLoaded)
            {
                Dictionary<string, object> filteredData = BetterChatFilter.Call<Dictionary<string, object>>("Filter", sender, message);
                if (filteredData != null && filteredData.Count > 0)
                {
                    message = filteredData["Message"].ToString();
                }
            }

            if (config.LogChatMessages)
            {
                LogToFile("log", message, this);
#if RUST
                if (!sender.IsConnected)
                {
                    UnityEngine.Debug.Log($"[Chat] Discord User[0] : {sender.Name}[{sender.Id}]: {message}");
                }
                else
                {
                    Log($"[{(ConVar.Chat.ChatChannel)channel}] {sender.Name}: {message}");
                }

                string prefixColor = sender.IsAdmin ? "#aaff55" : "#55aaff";
                Facepunch.RCon.Broadcast(Facepunch.RCon.LogType.Chat, new ConVar.Chat.ChatEntry
                {
                    Channel = (ConVar.Chat.ChatChannel)channel,
                    Message = message,
                    UserId = sender.Id,
                    Username = sender.Name,
                    Color = prefixColor,
                    Time = Facepunch.Math.Epoch.Current
                });
#else
                Log($"{sender.Name}: {message}");
#endif
            }

            switch ((ConVar.Chat.ChatChannel)channel)
            {
#if RUST
                case ConVar.Chat.ChatChannel.Team:
                    BasePlayer basePlayer = sender.Object as BasePlayer;
                    RelationshipManager.PlayerTeam team = basePlayer.Team;
                    if (team != null && team.members.Count != 0)
                    {
                        foreach (ulong member in team.members)
                        {
                            BasePlayer targetBasePlayer = RelationshipManager.FindByID(member);
                            if (targetBasePlayer != null && targetBasePlayer.IsConnected)
                            {
                                if (blockedReceivers == null || !blockedReceivers.Contains(targetBasePlayer.IPlayer.Id))
                                {
                                    TranslateChat(targetBasePlayer.IPlayer, sender, message, channel);
                                }
                            }
                        }
                    }
                    break;

                case ConVar.Chat.ChatChannel.Clan:
                    BasePlayer clanPlayer = sender.Object as BasePlayer;
                    long clanid = clanPlayer.clanId;
                    if (clanid != 0)
                    {
                        IClan clan = null;
                        if (ClanManager.ServerInstance.Backend?.TryGet(clanid, out clan) ?? false)
                        {
                            foreach (ClanMember member in clan.Members)
                            {
                                BasePlayer targetBasePlayer = BasePlayer.FindByID(member.SteamId);
                                if (targetBasePlayer != null && targetBasePlayer.IsConnected)
                                {
                                    if (blockedReceivers == null || !blockedReceivers.Contains(targetBasePlayer.IPlayer.Id))
                                    {
                                        TranslateChat(targetBasePlayer.IPlayer, sender, message, channel);
                                    }
                                }
                            }
                        }
                    }
                    break;

                case ConVar.Chat.ChatChannel.Cards:
                    BasePlayer cardPlayer = sender.Object as BasePlayer;
                    BaseCardGameEntity baseCardGame = cardPlayer.GetMountedVehicle() as BaseCardGameEntity;
                    if (baseCardGame != null)
                    {
                        foreach (CardPlayerData playerData in baseCardGame.GameController.PlayerData)
                        {
                            if (playerData.HasUser)
                            {
                                BasePlayer targetBasePlayer = BasePlayer.FindByID(playerData.UserID);
                                if (targetBasePlayer != null && targetBasePlayer.IsConnected)
                                {
                                    if (blockedReceivers == null || !blockedReceivers.Contains(targetBasePlayer.IPlayer.Id))
                                    {
                                        TranslateChat(targetBasePlayer.IPlayer, sender, message, channel);
                                    }
                                }
                            }
                        }
                    }
                    break;

                case ConVar.Chat.ChatChannel.Local:
                    BasePlayer localPlayer = sender.Object as BasePlayer;
                    float localRange = ConVar.Chat.localChatRange * ConVar.Chat.localChatRange;
                    foreach (BasePlayer targetBasePlayer in BasePlayer.activePlayerList)
                    {
                        if (blockedReceivers == null || !blockedReceivers.Contains(targetBasePlayer.UserIDString))
                        {
                            if ((localPlayer.transform.position - targetBasePlayer.transform.position).sqrMagnitude <= localRange)
                            {
                                TranslateChat(targetBasePlayer.IPlayer, sender, message, channel);
                            }
                        }
                    }
                    break;
#endif
                default:
                    foreach (IPlayer target in players.Connected)
                    {
                        if (blockedReceivers == null || !blockedReceivers.Contains(target.Id))
                        {
                            TranslateChat(target, sender, message, channel);
                        }
                    }
                    break;
            }

            return true;
        }

        private object OnBetterChat(Dictionary<string, object> data)
        {
            HandleChat(data["Player"] as IPlayer, data["Message"] as string, (int)data["ChatChannel"], data["BlockedReceivers"] as List<string>);
            data["CancelOption"] = 2;
            return data;
        }

#if RUST
        private object OnPlayerChat(BasePlayer basePlayer, string message, ConVar.Chat.ChatChannel channel)
        {
            if (BetterChat == null || !BetterChat.IsLoaded)
            {
                return HandleChat(basePlayer.IPlayer, message, (int)channel);
            }

            return null;
        }
#else
        private object OnUserChat(IPlayer player, string message)
        {
            if (BetterChat == null || !BetterChat.IsLoaded)
            {
                return HandleChat(player, message);
            }

            return null;
        }
#endif

        #endregion Chat Handling
    }
}

Key Changes & Enhancements in Version 3.5.1

1. New Configuration Option: Language Auto-Detection

  • Added Configuration Setting:Auto-detect sender language (Defaults to true).

  • Logic Shift: In 2.2.2, the plugin strictly pulled the sender's language from their profile using lang.GetLanguage(senderId) ?? "auto". In 3.5.1, if Auto-detect sender language is enabled, it bypasses the profile look-up and enforces "auto", allowing the underlying TranslationAPI to dynamically detect the incoming language text.

2. Multi-Channel Support for Rust

The original version only natively handled Global and Team chat channels. Version 3.5.1 introduces full support for Rust's expanded chat ecosystem:

  • Clan Chat Support: Dynamically pulls the sender's clanId via ClanManager and propagates translated messages to online clan members.

  • Card Table Chat Support: Hooks into BaseCardGameEntity to fetch active players sitting at a card table, ensuring text is localized exclusively for players in that match.

  • Local Chat Support: Handles proximity-based chat by calculating distance using ConVar.Chat.localChatRange to only translate and send messages to players within physical range.

  • Refactored Channel Logic: Shifted the channel switch block evaluation from arbitrary integers (case 1:) to explicit Oxide enum values (ConVar.Chat.ChatChannel.Team, Clan, Cards, Local).

3. Rewritten Logging & RCON Behavior

  • Pre-Translation Logging: Version 2.2.2 logged the chat after translation within the target delivery method (ProcessMessage), resulting in duplicated logs per recipient. Version 3.5.1 moves logging into HandleChat so that the original incoming message is logged exactly once when it hits the server.

  • Discord/Offline User Awareness: Added safety checks for non-connected/virtual senders (e.g., Discord bridge extensions routing through Oxide's IPlayer) to cleanly output messages to the Unity log stream without generating empty entity errors.

4. Bug Fixes & Code Cleanliness

  • Prefix Lookups: In 2.2.2, when ShowBothMessages was enabled, the secondary metadata call used sender.Id to fetch the prefix translation (Translate("Translation", sender.Id, ...)). Version 3.5.1 fixes this context to use target.Id, ensuring the translation helper text itself matches the receiver's local interface language.

  • Dependency Cleanliness: Extra conditional directives (using Facepunch; and using Facepunch.CardGames;) were added inside #if RUST guards to cleanly expose card table logic without breaking compilation routines on non-Rust Oxide titles.

Code Diff Summary Table

Feature / MethodOriginal (2.2.2)Latest Fork (3.5.1)
Plugin Version2.2.23.5.1
Source Language Configurationhardcoded fallbackAuto-detect sender language toggle
Logging PointInside ProcessMessage (Post-Translation)Inside HandleChat (Pre-Translation)
Rust Chat Channels CoveredGlobal, Team (case 1)Global, Team, Clan, Cards, Proximity Local
Prefix Text Target ID Fixsender.Idtarget.Id