using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Text.RegularExpressions; using Newtonsoft.Json; using Oxide.Core; using Oxide.Core.Libraries.Covalence; using Oxide.Core.Plugins; using UnityEngine; namespace Oxide.Plugins { [Info("Rustron Companion", "Rustron", "1.0.0")] [Description("Structured data API for the Rustron mobile app, served over the existing RCON connection")] public class RustronCompanion : RustPlugin { [PluginReference] private Plugin BetterChat, Clans, Economics, SkillTree; private const int ProtocolVersion = 1; private const string PermUse = "rustroncompanion.use"; // Commands are namespaced away from this server's existing Rustron* plugin // family, which already collides with itself (RustronCore took "balance" // from Economics, RustronUI took "top" from RustronCore). private const string Tag = "RUSTRONAPP"; private readonly HashSet _tracked = new HashSet(); private readonly HashSet _cupboards = new HashSet(); private List _monumentCache; // World size and seed are constant for a running map. private int _worldSize = -1; private int _worldSeed = -1; private int WorldSize => _worldSize >= 0 ? _worldSize : (_worldSize = Safe(() => (int)World.Size, 0)); private int WorldSeed => _worldSeed >= 0 ? _worldSeed : (_worldSeed = Safe(() => (int)World.Seed, 0)); #region Lifecycle private void Init() { permission.RegisterPermission(PermUse, this); LoadChatLog(); } private void OnServerInitialized() { // One full sweep at boot, then hooks keep the sets current. foreach (var networkable in BaseNetworkable.serverEntities) { var entity = networkable as BaseEntity; if (entity == null) continue; TrackIfInteresting(entity); } Puts($"Ready. Protocol {ProtocolVersion}, commands namespaced under 'rustronapp.'. " + $"Tracking {_tracked.Count} world entities and {_cupboards.Count} cupboards."); } private void OnEntitySpawned(BaseNetworkable networkable) { var entity = networkable as BaseEntity; if (entity != null) TrackIfInteresting(entity); } private void OnEntityKill(BaseNetworkable networkable) { var entity = networkable as BaseEntity; if (entity == null) return; _tracked.Remove(entity); var cupboard = entity as BuildingPrivlidge; if (cupboard != null) _cupboards.Remove(cupboard); } /// /// Runs on every entity spawn, which on a busy server is thousands of calls a /// minute, so it must stay cheap. Direct `is` tests compile to fast type /// checks; the reflection-based IsAssignableFrom loop this replaced was doing /// real work on every barrel and corpse that spawned. /// /// `is` still matches subclasses, so plugin-spawned variants such as /// CustomBradley are picked up exactly as before. /// private void TrackIfInteresting(BaseEntity entity) { var cupboard = entity as BuildingPrivlidge; if (cupboard != null) { _cupboards.Add(cupboard); return; } if (entity is BradleyAPC || entity is PatrolHelicopter || entity is CargoShip || entity is CH47Helicopter || entity is CargoPlane || entity is SupplyDrop || entity is HackableLockedCrate) { _tracked.Add(entity); } } #endregion #region Access & replies /// /// RCON and the server console are trusted (they already hold the RCON /// password). In-game callers need the permission. /// private bool Denied(ConsoleSystem.Arg arg) { if (arg.Connection == null) return false; var player = arg.Connection.player as BasePlayer; if (player != null && permission.UserHasPermission(player.UserIDString, PermUse)) return false; Reply(arg, new Dictionary { ["error"] = "no_permission" }); return true; } /// /// Replies to the calling connection only. Using Puts here instead would /// broadcast every payload to all RCON listeners, including any web panel /// the operator has open. /// private void Reply(ConsoleSystem.Arg arg, object payload) { try { arg.ReplyWith(JsonConvert.SerializeObject(payload)); } catch (Exception e) { arg.ReplyWith("{\"error\":\"serialize_failed\",\"detail\":" + JsonConvert.SerializeObject(e.Message) + "}"); } } private static float Round(float value) => (float)Math.Round(value, 1); /// Yaw in degrees clockwise from north, derived from a look vector. private static float Yaw(Vector3 forward) => Round(Mathf.Atan2(forward.x, forward.z) * Mathf.Rad2Deg); private static T Safe(Func get, T fallback) { try { return get(); } catch { return fallback; } } #endregion #region Commands [ConsoleCommand("rustronapp.version")] private void CmdVersion(ConsoleSystem.Arg arg) { if (Denied(arg)) return; Reply(arg, new Dictionary { ["plugin"] = Name, ["version"] = Version.ToString(), ["protocol"] = ProtocolVersion, ["features"] = new[] { "snapshot", "monuments", "entities", "player", "chat", "mapimage", "cupboards", "teleport", "chatlog", "config", "chatsend" }, ["betterChat"] = BetterChat != null, ["clans"] = Clans != null, ["economics"] = Economics != null, ["skillTree"] = SkillTree != null }); } [ConsoleCommand("rustronapp.snapshot")] private void CmdSnapshot(ConsoleSystem.Arg arg) { if (Denied(arg)) return; var players = new List(); foreach (var player in BasePlayer.activePlayerList) { if (player == null || !player.IsConnected) continue; var pos = player.transform.position; players.Add(new Dictionary { ["id"] = player.UserIDString, ["name"] = player.displayName, ["x"] = Round(pos.x), ["y"] = Round(pos.y), ["z"] = Round(pos.z), ["yaw"] = Safe(() => Yaw(player.eyes.HeadForward()), 0f), ["hp"] = (int)player.health, ["ping"] = Safe(() => Network.Net.sv.GetAveragePing(player.Connection), 0), ["team"] = player.currentTeam.ToString(), ["connected"] = Safe(() => (int)(Time.realtimeSinceStartup - player.Connection.connectionTime), 0), ["sleeping"] = player.IsSleeping(), ["admin"] = player.IsAdmin }); } var sleepers = new List(); foreach (var player in BasePlayer.sleepingPlayerList) { if (player == null) continue; var pos = player.transform.position; sleepers.Add(new Dictionary { ["id"] = player.UserIDString, ["name"] = player.displayName, ["x"] = Round(pos.x), ["z"] = Round(pos.z) }); } Reply(arg, new Dictionary { ["t"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), ["server"] = new Dictionary { ["hostname"] = ConVar.Server.hostname, ["level"] = ConVar.Server.level, ["maxPlayers"] = ConVar.Server.maxplayers, ["players"] = players.Count, ["sleepers"] = sleepers.Count, ["queued"] = Safe(() => ServerMgr.Instance.connectionQueue.Queued, 0), ["joining"] = Safe(() => ServerMgr.Instance.connectionQueue.Joining, 0), ["fps"] = Safe(() => Performance.report.frameRate, 0), ["entities"] = Safe(() => BaseNetworkable.serverEntities.Count, 0), ["memory"] = Safe(() => (int)(Performance.report.memoryUsageSystem), 0), ["uptime"] = (int)Time.realtimeSinceStartup, // Fixed for the life of the map - resolved once rather than paying // for a guarded lookup on every poll. ["worldSize"] = WorldSize, ["seed"] = WorldSeed, ["gameTime"] = Safe(() => Round(TOD_Sky.Instance.Cycle.Hour), 0f) }, ["players"] = players, ["sleeping"] = sleepers }); } [ConsoleCommand("rustronapp.monuments")] private void CmdMonuments(ConsoleSystem.Arg arg) { if (Denied(arg)) return; // Monuments never move, so this is built once and reused. if (_monumentCache == null) { _monumentCache = new List(); var monuments = Safe(() => TerrainMeta.Path?.Monuments, null); if (monuments != null) { foreach (var monument in monuments) { if (monument == null) continue; var pos = monument.transform.position; _monumentCache.Add(new Dictionary { ["name"] = MonumentName(monument), ["x"] = Round(pos.x), ["z"] = Round(pos.z), ["type"] = monument.Type.ToString() }); } } } Reply(arg, new Dictionary { ["worldSize"] = WorldSize, ["monuments"] = _monumentCache }); } /// /// Prefers the localised display name; custom and RustEdit monuments often /// have none, so fall back to a tidied prefab name rather than showing blank. /// private static string MonumentName(MonumentInfo monument) { var display = Safe(() => monument.displayPhrase?.english, null); if (!string.IsNullOrEmpty(display)) return display.Trim(); var name = monument.name ?? "Monument"; var slash = name.LastIndexOf('/'); if (slash >= 0) name = name.Substring(slash + 1); return name.Replace(".prefab", "").Replace("_", " ").Trim(); } [ConsoleCommand("rustronapp.entities")] private void CmdEntities(ConsoleSystem.Arg arg) { if (Denied(arg)) return; var list = new List(); _tracked.RemoveWhere(e => e == null || e.IsDestroyed); foreach (var entity in _tracked) { var pos = entity.transform.position; list.Add(new Dictionary { ["kind"] = entity.GetType().Name, ["id"] = entity.net?.ID.ToString(), ["x"] = Round(pos.x), ["z"] = Round(pos.z), ["y"] = Round(pos.y) }); } Reply(arg, new Dictionary { ["t"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), ["entities"] = list }); } [ConsoleCommand("rustronapp.player")] private void CmdPlayer(ConsoleSystem.Arg arg) { if (Denied(arg)) return; var id = arg.GetString(0, null); if (string.IsNullOrEmpty(id)) { Reply(arg, new Dictionary { ["error"] = "usage: rustronapp.player " }); return; } var player = BasePlayer.activePlayerList.FirstOrDefault(p => p != null && p.UserIDString == id) ?? BasePlayer.sleepingPlayerList.FirstOrDefault(p => p != null && p.UserIDString == id); if (player == null) { Reply(arg, new Dictionary { ["error"] = "not_found", ["id"] = id }); return; } var pos = player.transform.position; var payload = new Dictionary { ["id"] = player.UserIDString, ["name"] = player.displayName, ["sleeping"] = player.IsSleeping(), ["connected"] = player.IsConnected, ["x"] = Round(pos.x), ["y"] = Round(pos.y), ["z"] = Round(pos.z), ["vitals"] = new Dictionary { ["health"] = Round(player.health), ["calories"] = Safe(() => Round(player.metabolism.calories.value), 0f), ["hydration"] = Safe(() => Round(player.metabolism.hydration.value), 0f), ["radiation"] = Safe(() => Round(player.metabolism.radiation_level.value), 0f), ["temperature"] = Safe(() => Round(player.metabolism.temperature.value), 0f) }, ["team"] = player.currentTeam.ToString(), ["inventory"] = new Dictionary { ["belt"] = Items(Safe(() => player.inventory.containerBelt, null)), ["main"] = Items(Safe(() => player.inventory.containerMain, null)), ["wear"] = Items(Safe(() => player.inventory.containerWear, null)) }, ["cupboards"] = Cupboards(player) }; // Optional integrations - absent plugins simply omit their field. var balance = Safe(() => Economics?.Call("Balance", player.userID), null); if (balance != null) payload["balance"] = balance; var clan = Safe(() => Clans?.Call("GetClanOf", player.userID), null); if (!string.IsNullOrEmpty(clan)) payload["clan"] = clan; var level = Safe(() => SkillTree?.Call("GetPlayerLevel", player.userID), null); if (level != null) payload["level"] = level; Reply(arg, payload); } private static List Items(ItemContainer container) { var result = new List(); if (container?.itemList == null) return result; foreach (var item in container.itemList) { if (item?.info == null) continue; result.Add(new Dictionary { ["shortname"] = item.info.shortname, ["amount"] = item.amount, ["condition"] = item.hasCondition ? (object)Round(item.condition) : null }); } return result; } private List Cupboards(BasePlayer player) { var result = new List(); _cupboards.RemoveWhere(c => c == null || c.IsDestroyed); foreach (var cupboard in _cupboards) { // authorizedPlayers is a plain List in this build, not a list // of name/id pairs as older plugin examples assume. var authorized = Safe( () => cupboard.authorizedPlayers != null && cupboard.authorizedPlayers.Any(a => a == player.userID), false); if (!authorized) continue; var pos = cupboard.transform.position; result.Add(new Dictionary { ["x"] = Round(pos.x), ["z"] = Round(pos.z), ["owner"] = cupboard.OwnerID == player.userID }); } return result; } /// /// Teleports a player to coordinates or to another player. /// /// Vanilla's teleport commands are almost all caller-relative ("teleports the /// calling admin..."), which is useless over RCON where there is no caller. /// Doing it here works for any target, connected or sleeping. /// /// rustronapp.teleport <steamid> <x> <z> /// rustronapp.teleport <steamid> <x> <y> <z> /// rustronapp.teleport <steamid> to <targetSteamId> /// [ConsoleCommand("rustronapp.teleport")] private void CmdTeleport(ConsoleSystem.Arg arg) { if (Denied(arg)) return; var id = arg.GetString(0, null); if (string.IsNullOrEmpty(id)) { Reply(arg, new Dictionary { ["error"] = "usage: rustronapp.teleport [y] | to " }); return; } var player = FindPlayer(id); if (player == null) { Reply(arg, new Dictionary { ["error"] = "not_found", ["id"] = id }); return; } Vector3 destination; string describedAs; if (string.Equals(arg.GetString(1, null), "to", StringComparison.OrdinalIgnoreCase)) { var target = FindPlayer(arg.GetString(2, null)); if (target == null) { Reply(arg, new Dictionary { ["error"] = "target_not_found" }); return; } destination = target.transform.position; describedAs = target.displayName; } else { var x = arg.GetFloat(1, float.NaN); var third = arg.GetFloat(2, float.NaN); var fourth = arg.GetFloat(3, float.NaN); float y, z; if (float.IsNaN(fourth)) { // Two coordinates given: treat them as x/z and drop onto terrain. z = third; y = float.NaN; } else { y = third; z = fourth; } if (float.IsNaN(x) || float.IsNaN(z)) { Reply(arg, new Dictionary { ["error"] = "bad_coordinates" }); return; } if (float.IsNaN(y)) y = GroundHeight(x, z); destination = new Vector3(x, y, z); describedAs = $"{x:F0}, {z:F0}"; } try { // Dismount and unparent first, otherwise a player in a vehicle or on a // lift is dragged straight back by whatever they were attached to. player.EnsureDismounted(); player.SetParent(null, true, true); player.Teleport(destination); Reply(arg, new Dictionary { ["ok"] = true, ["player"] = player.displayName, ["to"] = describedAs, ["x"] = Round(destination.x), ["y"] = Round(destination.y), ["z"] = Round(destination.z) }); } catch (Exception e) { Reply(arg, new Dictionary { ["error"] = "teleport_failed", ["detail"] = e.Message }); } } private static BasePlayer FindPlayer(string id) { if (string.IsNullOrEmpty(id)) return null; return BasePlayer.activePlayerList.FirstOrDefault(p => p != null && p.UserIDString == id) ?? BasePlayer.sleepingPlayerList.FirstOrDefault(p => p != null && p.UserIDString == id); } /// Terrain height at x/z, kept above water so a coastal drop doesn't /// land the player on the seabed. private static float GroundHeight(float x, float z) { var probe = new Vector3(x, 0f, z); var ground = Safe(() => TerrainMeta.HeightMap.GetHeight(probe), 0f); var water = Safe(() => TerrainMeta.WaterMap.GetHeight(probe), 0f); return Mathf.Max(ground, water) + 1.5f; } /// /// Every tool cupboard on the map. Capped, because a long-running server can /// hold thousands and both the payload and the app's rendering have limits. /// [ConsoleCommand("rustronapp.cupboards")] private void CmdCupboards(ConsoleSystem.Arg arg) { if (Denied(arg)) return; const int max = 1500; _cupboards.RemoveWhere(c => c == null || c.IsDestroyed); var list = new List(); var truncated = false; foreach (var cupboard in _cupboards) { if (list.Count >= max) { truncated = true; break; } var pos = cupboard.transform.position; list.Add(new Dictionary { ["x"] = Round(pos.x), ["z"] = Round(pos.z), ["owner"] = cupboard.OwnerID.ToString(), ["auth"] = Safe(() => cupboard.authorizedPlayers?.Count ?? 0, 0) }); } Reply(arg, new Dictionary { ["total"] = _cupboards.Count, ["truncated"] = truncated, ["cupboards"] = list }); } #endregion #region Map image // Rendering costs the better part of a second and blocks the main thread, so // it happens once on request and is then held in memory. The rendered image // covers exactly the world square with no ocean padding, unlike Facepunch's // hosted renders, so the app can map coordinates onto it 1:1. private string _mapBase64; private int _mapWidth; private int _mapHeight; private string _mapKey; /// Base64 characters per chunk. A 264KB single reply is known good; /// this stays well inside that. private const int ChunkChars = 128 * 1024; private void EnsureMapRendered() { var worldSize = WorldSize; var key = $"{Safe(() => (int)World.Seed, 0)}.{worldSize}"; if (_mapBase64 != null && _mapKey == key) return; // Aim for roughly a 1000px image regardless of world size: enough detail // for a phone without paying for a full-resolution render. var scale = worldSize > 0 ? Mathf.Clamp(1000f / worldSize, 0.1f, 1f) : 0.25f; int w, h; Color ocean; var bytes = MapImageRenderer.Render(out w, out h, out ocean, scale, false, false, 0); _mapWidth = w; _mapHeight = h; _mapKey = key; _mapBase64 = bytes != null ? Convert.ToBase64String(bytes) : string.Empty; } [ConsoleCommand("rustronapp.mapimage")] private void CmdMapImage(ConsoleSystem.Arg arg) { if (Denied(arg)) return; var mode = arg.GetString(0, "info"); if (mode.Equals("info", StringComparison.OrdinalIgnoreCase)) { EnsureMapRendered(); Reply(arg, new Dictionary { ["key"] = _mapKey, ["width"] = _mapWidth, ["height"] = _mapHeight, ["worldSize"] = WorldSize, ["chars"] = _mapBase64.Length, ["chunkChars"] = ChunkChars, ["chunks"] = Mathf.CeilToInt(_mapBase64.Length / (float)ChunkChars), // The render spans the world square exactly - no ocean padding. ["margin"] = 0 }); return; } int index; if (!int.TryParse(mode, out index) || index < 0) { Reply(arg, new Dictionary { ["error"] = "usage: rustronapp.mapimage " }); return; } EnsureMapRendered(); var start = index * ChunkChars; if (start >= _mapBase64.Length) { Reply(arg, new Dictionary { ["error"] = "chunk_out_of_range" }); return; } var length = Math.Min(ChunkChars, _mapBase64.Length - start); // Raw base64, not JSON: this is the bulk of the transfer and the payload // has no characters that would need escaping. arg.ReplyWith(_mapBase64.Substring(start, length)); } #endregion #region Plugin config // Plugin names are alphanumeric plus underscore/hyphen in practice; enforcing // that here (rather than trusting the caller) is what keeps this from turning // into an arbitrary-file read/write primitive via "../../" in the name. private static readonly Regex ConfigNamePattern = new Regex(@"^[A-Za-z0-9_-]+$"); private static bool ValidConfigName(string name) => !string.IsNullOrEmpty(name) && name.Length <= 128 && ConfigNamePattern.IsMatch(name); private static string ConfigPath(string name) => Path.Combine(Interface.Oxide.ConfigDirectory, name + ".json"); [ConsoleCommand("rustronapp.configlist")] private void CmdConfigList(ConsoleSystem.Arg arg) { if (Denied(arg)) return; try { var dir = Interface.Oxide.ConfigDirectory; var files = Directory.Exists(dir) ? Directory.GetFiles(dir, "*.json") .Select(p => Path.GetFileNameWithoutExtension(p)) .Where(ValidConfigName) .OrderBy(n => n, StringComparer.OrdinalIgnoreCase) .ToList() : new List(); Reply(arg, new Dictionary { ["files"] = files }); } catch (Exception e) { Reply(arg, new Dictionary { ["error"] = "list_failed", ["detail"] = e.Message }); } } [ConsoleCommand("rustronapp.configget")] private void CmdConfigGet(ConsoleSystem.Arg arg) { if (Denied(arg)) return; var name = arg.GetString(0, null); if (!ValidConfigName(name)) { Reply(arg, new Dictionary { ["error"] = "usage: rustronapp.configget " }); return; } var path = ConfigPath(name); if (!File.Exists(path)) { Reply(arg, new Dictionary { ["error"] = "not_found", ["name"] = name }); return; } try { var text = File.ReadAllText(path, Encoding.UTF8); Reply(arg, new Dictionary { ["name"] = name, ["content"] = text }); } catch (Exception e) { Reply(arg, new Dictionary { ["error"] = "read_failed", ["detail"] = e.Message }); } } /// /// rustronapp.configset <name> <base64Json> - the payload travels base64-encoded /// so a JSON body full of quotes and newlines survives the console command's /// own tokenizing unscathed. The previous file is kept alongside as ".bak" /// before being overwritten, since this is a straight file replace with no undo. /// [ConsoleCommand("rustronapp.configset")] private void CmdConfigSet(ConsoleSystem.Arg arg) { if (Denied(arg)) return; var name = arg.GetString(0, null); var payload = arg.GetString(1, null); if (!ValidConfigName(name) || string.IsNullOrEmpty(payload)) { Reply(arg, new Dictionary { ["error"] = "usage: rustronapp.configset " }); return; } string json; try { json = Encoding.UTF8.GetString(Convert.FromBase64String(payload)); } catch (Exception) { Reply(arg, new Dictionary { ["error"] = "bad_base64" }); return; } try { // Round-tripping through the JSON object both validates it and gives // consistent formatting regardless of how the app sent it. var parsed = JsonConvert.DeserializeObject(json); json = JsonConvert.SerializeObject(parsed, Formatting.Indented); } catch (Exception e) { Reply(arg, new Dictionary { ["error"] = "invalid_json", ["detail"] = e.Message }); return; } var path = ConfigPath(name); try { if (File.Exists(path)) File.Copy(path, path + ".bak", true); File.WriteAllText(path, json, Encoding.UTF8); Reply(arg, new Dictionary { ["ok"] = true, ["name"] = name, ["bytes"] = json.Length }); } catch (Exception e) { Reply(arg, new Dictionary { ["error"] = "write_failed", ["detail"] = e.Message }); } } #endregion #region Chat log /// /// Server-side chat buffer, so the app can collect anything said while it was /// closed instead of only seeing messages that arrive live. /// private class ChatRecord { [JsonProperty("seq")] public long Seq { get; set; } [JsonProperty("id")] public string Id { get; set; } [JsonProperty("name")] public string Name { get; set; } [JsonProperty("message")] public string Message { get; set; } [JsonProperty("channel")] public string Channel { get; set; } [JsonProperty("t")] public long Time { get; set; } [JsonProperty("titles")] public List Titles { get; set; } [JsonProperty("nameColor")] public string NameColor { get; set; } [JsonProperty("group")] public string Group { get; set; } } private const int ChatLogCapacity = 1000; private const int ChatLogMaxPerRequest = 300; private const string ChatLogFile = "RustronCompanion/chatlog"; private List _chatLog = new List(); private long _chatSeq; private bool _chatDirty; private void LoadChatLog() { try { var stored = Interface.Oxide.DataFileSystem.ReadObject>(ChatLogFile); if (stored != null && stored.Count > 0) { _chatLog = stored; _chatSeq = stored[stored.Count - 1].Seq; } } catch { // A corrupt or absent file just means starting fresh. _chatLog = new List(); } } private void SaveChatLog(bool force = false) { if (!_chatDirty && !force) return; try { Interface.Oxide.DataFileSystem.WriteObject(ChatLogFile, _chatLog); _chatDirty = false; } catch { // Losing the archive is survivable; never break chat over it. } } // Written on the server's own save cycle rather than per message, so a busy // server isn't doing disk I/O on every line of chat. private void OnServerSave() => SaveChatLog(); private void Unload() => SaveChatLog(true); /// /// rustronapp.chatlog [sinceSeq] [limit] - messages newer than sinceSeq. /// Omit sinceSeq for the most recent block. /// [ConsoleCommand("rustronapp.chatlog")] private void CmdChatLog(ConsoleSystem.Arg arg) { if (Denied(arg)) return; var since = (long)arg.GetFloat(0, -1f); var limit = Mathf.Clamp(arg.GetInt(1, 100), 1, ChatLogMaxPerRequest); List selected; if (since < 0) { var start = Math.Max(0, _chatLog.Count - limit); selected = _chatLog.GetRange(start, _chatLog.Count - start); } else { selected = _chatLog.Where(c => c.Seq > since).Take(limit).ToList(); } Reply(arg, new Dictionary { ["latest"] = _chatSeq, ["oldest"] = _chatLog.Count > 0 ? _chatLog[0].Seq : 0, ["count"] = selected.Count, // True when the caller is further behind than the buffer reaches, so // the app can tell "you're up to date" from "some was lost". ["gap"] = since >= 0 && _chatLog.Count > 0 && since < _chatLog[0].Seq - 1, ["messages"] = selected }); } #endregion #region Chat relay /// /// BetterChat strips colour markup from the line it writes to console, so the /// app cannot recover real tag colours from RCON alone. Asking BetterChat for /// the structured message gives the titles with their markup intact. /// Emitted via Puts (broadcast) so the app receives it live. /// /// /// Channel isn't part of the OnBetterChat payload, so it's bridged over from /// this hook, keyed per player rather than kept in one shared field - with /// more than one player chatting, a single shared "last channel" gets /// overwritten by whoever's message lands in between, so a Team message could /// silently pick up whatever channel the next Global message used instead. /// private readonly Dictionary _pendingChatChannel = new Dictionary(); private void OnPlayerChat(BasePlayer player, string message, ConVar.Chat.ChatChannel channel) { if (player == null || string.IsNullOrEmpty(message)) return; _pendingChatChannel[player.UserIDString] = channel.ToString(); // With BetterChat installed, OnBetterChat below is the better source: it // carries the finished title list. Handling it here too would duplicate. if (BetterChat != null) return; CommitChat(NewChatRecord(player.UserIDString, player.displayName, message, channel.ToString())); } /// /// Fires with the message BetterChat is about to send, after every title has /// been resolved - including third-party ones such as the level tag, which /// API_GetMessageData does not return when queried separately. /// /// Returns void deliberately: a non-null return would rewrite the message. /// private void OnBetterChat(Dictionary data) { if (data == null) return; try { var iplayer = Get(data, "Player"); var message = Get(data, "Message"); if (iplayer == null || string.IsNullOrEmpty(message)) return; string channel; if (!_pendingChatChannel.TryGetValue(iplayer.Id, out channel)) channel = "Global"; _pendingChatChannel.Remove(iplayer.Id); var record = NewChatRecord( iplayer.Id, Get(data, "Username") ?? iplayer.Name, message, channel); record.Titles = Get>(data, "Titles"); record.Group = Get(data, "PrimaryGroup"); record.NameColor = NestedColor(data, "UsernameSettings"); CommitChat(record); } catch { // Never let the relay interfere with chat itself. } } private static T Get(Dictionary data, string key) where T : class { object value; return data.TryGetValue(key, out value) ? value as T : null; } /// /// Set while rustronapp.chat is broadcasting its own OnServerMessage call /// below, so this plugin doesn't re-commit a message it already logged /// itself - only other plugins (e.g. a Discord relay) need to see that call. /// private bool _suppressOwnServerMessage; /// /// Server broadcasts ("say", plugin announcements) don't go through /// OnPlayerChat because there's no BasePlayer behind them - without this they /// would show live but be missing from the backlog. /// /// Deferred like rustronapp.chat's own commit: "say" (and any other trigger /// of this hook) can just as easily be running inside someone else's pending /// RCON command - a Discord relay bot's own "say" call, for instance - so the /// same WebRcon reply-folding problem applies here, not just to our own /// commands. /// private void OnServerMessage(string message, string name, string color, ulong id) { if (string.IsNullOrEmpty(message) || _suppressOwnServerMessage) return; var record = NewChatRecord(id.ToString(), string.IsNullOrEmpty(name) ? "SERVER" : name, message, "Server"); record.NameColor = color; CommitChat(record, deferBroadcast: true); } private ChatRecord NewChatRecord(string id, string name, string message, string channel) => new ChatRecord { Seq = ++_chatSeq, Id = id, Name = name, Message = message, Channel = channel, Time = DateTimeOffset.UtcNow.ToUnixTimeSeconds() }; /// /// deferBroadcast matters for anything committed from inside a console command /// this plugin is still handling (rustronapp.chat, namely): Rust's WebRcon /// folds any console output produced while a command executes into that /// command's own reply, which only ever reaches the connection that issued /// it - never other RCON listeners such as a Discord relay bot, no matter /// what Type or channel the message carries. Pushing the Puts() to the next /// tick lets it go out as a genuine standalone broadcast once the command has /// already returned. Hook-driven commits (player chat, OnServerMessage) don't /// need this - they aren't running inside anyone's pending RCON reply. /// private void CommitChat(ChatRecord record, bool deferBroadcast = false) { _chatLog.Add(record); if (_chatLog.Count > ChatLogCapacity) _chatLog.RemoveRange(0, _chatLog.Count - ChatLogCapacity); _chatDirty = true; if (deferBroadcast) timer.Once(0f, () => PutsChatRelay(record)); else PutsChatRelay(record); } private void PutsChatRelay(ChatRecord record) { try { // The same sequence number goes out live and comes back from the // backlog, so the app can de-duplicate the overlap exactly rather // than guessing from timestamps and text. Puts($"{Tag}|CHAT|{JsonConvert.SerializeObject(record)}"); } catch { // Never let the relay interfere with chat itself. } } private static string NestedColor(Dictionary data, string key) { object section; if (!data.TryGetValue(key, out section)) return null; var dict = section as Dictionary; if (dict == null) return null; object color; return dict.TryGetValue("Color", out color) ? color as string : null; } /// /// rustronapp.chat <base64Json> - lets the app broadcast under a chosen /// name/colour (typically the operator's own handle) instead of the generic /// "SERVER" identity a plain `say` produces. /// /// Server.Broadcast can't take a custom colour - its 3-arg overload's third /// parameter is the userid, not a colour, and the colour OnServerMessage /// reports for it is whatever Oxide hardcodes internally, not something this /// plugin controls. So instead of fighting that, this sends the fully /// formatted "<color>name</color>: message" line straight to chat.add on /// every client, then records it itself with the real name/colour rather than /// relying on OnServerMessage to relay it back. /// [ConsoleCommand("rustronapp.chat")] private void CmdChat(ConsoleSystem.Arg arg) { if (Denied(arg)) return; var payload = arg.GetString(0, null); if (string.IsNullOrEmpty(payload)) { Reply(arg, new Dictionary { ["error"] = "usage: rustronapp.chat " }); return; } string json; try { json = Encoding.UTF8.GetString(Convert.FromBase64String(payload)); } catch (Exception) { Reply(arg, new Dictionary { ["error"] = "bad_base64" }); return; } string message, name, color; try { var data = JsonConvert.DeserializeObject>(json); message = data != null && data.ContainsKey("message") ? Convert.ToString(data["message"]) : null; name = data != null && data.ContainsKey("name") ? Convert.ToString(data["name"]) : null; color = data != null && data.ContainsKey("color") ? Convert.ToString(data["color"]) : null; } catch (Exception e) { Reply(arg, new Dictionary { ["error"] = "invalid_json", ["detail"] = e.Message }); return; } if (string.IsNullOrEmpty(message)) { Reply(arg, new Dictionary { ["error"] = "empty_message" }); return; } if (string.IsNullOrEmpty(name)) name = "SERVER"; if (string.IsNullOrEmpty(color)) color = "#5af"; try { // Server.Broadcast(message, prefix, userid) is Rust's native broadcast // path - the same one `say` uses. Colour is baked into the prefix as // markup, since Broadcast has no separate colour parameter; the local // plugins on this server already lean on the same trick for the message // body (e.g. RustronEvents' "[TAG]"). // // It also fires OnServerMessage itself, so the suppression flag stops // the handler above from committing this message to the log a second // time. // // (A Discord relay watching RCON can't tell this apart from any other // plugin's Server.Broadcast announcement via WebRcon's message Type or // channel - reflecting Oxide.Rust.dll confirmed Broadcast calls // ConsoleNetwork.BroadcastToAllClients internally, the same client-only // dispatch as a manual chat.add loop, not the native chat pipeline that // gets tagged Type "Chat". The CommitChat call below's own // "RUSTRONAPP|CHAT|{...}" relay line, with "channel":"App", is what a // relay bot should watch for instead - see UltimateRconPlus's // handleAppBroadcastMessage.) var coloredName = $"{name}"; _suppressOwnServerMessage = true; try { Server.Broadcast(message, coloredName, 0UL); } finally { _suppressOwnServerMessage = false; } var record = NewChatRecord("0", name, message, "App"); record.NameColor = color; CommitChat(record, deferBroadcast: true); // Handed back so the app can show its own message immediately with the // real seq/timestamp already attached, instead of waiting for a relay // broadcast that (per Rust's WebRcon) gets folded into this same reply // rather than delivered as a separate live message. Reply(arg, new Dictionary { ["ok"] = true, ["seq"] = record.Seq, ["at"] = record.Time }); } catch (Exception e) { Reply(arg, new Dictionary { ["error"] = "broadcast_failed", ["detail"] = e.Message }); } } #endregion } }