//#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
}
}
Chat Translator v3
Key Changes & Enhancements in Version 3.5.1
1. New Configuration Option: Language Auto-Detection
Added Configuration Setting:
Auto-detect sender language(Defaults totrue).Logic Shift: In
2.2.2, the plugin strictly pulled the sender's language from their profile usinglang.GetLanguage(senderId) ?? "auto". In3.5.1, ifAuto-detect sender languageis 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
clanIdviaClanManagerand propagates translated messages to online clan members.Card Table Chat Support: Hooks into
BaseCardGameEntityto 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.localChatRangeto only translate and send messages to players within physical range.Refactored Channel Logic: Shifted the channel
switchblock 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.2logged the chat after translation within the target delivery method (ProcessMessage), resulting in duplicated logs per recipient. Version3.5.1moves logging intoHandleChatso 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, whenShowBothMessageswas enabled, the secondary metadata call usedsender.Idto fetch the prefix translation (Translate("Translation", sender.Id, ...)). Version3.5.1fixes this context to usetarget.Id, ensuring the translation helper text itself matches the receiver's local interface language.Dependency Cleanliness: Extra conditional directives (
using Facepunch;andusing Facepunch.CardGames;) were added inside#if RUSTguards to cleanly expose card table logic without breaking compilation routines on non-Rust Oxide titles.
Code Diff Summary Table
| Feature / Method | Original (2.2.2) | Latest Fork (3.5.1) |
| Plugin Version | 2.2.2 | 3.5.1 |
| Source Language Configuration | hardcoded fallback | Auto-detect sender language toggle |
| Logging Point | Inside ProcessMessage (Post-Translation) | Inside HandleChat (Pre-Translation) |
| Rust Chat Channels Covered | Global, Team (case 1) | Global, Team, Clan, Cards, Proximity Local |
| Prefix Text Target ID Fix | sender.Id | target.Id |