// VoxelRank vote rewards for Rust (Oxide / uMod and Carbon). // // Players vote on your VoxelRank vote page, either signed in with Steam or by // typing their in-game name, then type /claim in game to collect the reward. // // /vote prints your vote link // /claim checks for a vote in the last 24 hours and gives the rewards // // Install: copy this file to oxide/plugins (Oxide) or carbon/plugins (Carbon), // then open the config it writes (oxide/config/VoxelRank.json or // carbon/configs/VoxelRank.json), paste your listing's API key and set the // rewards. Reload with `oxide.reload VoxelRank` (Oxide) or `c.reload VoxelRank` // (Carbon). // // How a claim works (every call is a web request queued with webrequest, the // main thread never waits on the network): // // 1. GET /api/v1/votes/check?steamid= status 0 = no vote in 24h, // 1 = voted, not claimed yet, // 2 = voted and already claimed // 2. POST /api/v1/votes/claim {"steamid":..} claims it. The site marks the // vote claimed in one database statement, so two /claim calls at the // same moment can never both get the reward: only the call whose answer // says "claimed" > 0 gives anything. // 3. If there is no unclaimed Steam vote and "Accept votes cast with a typed name" is // on, POST /api/v1/votes/claim {"username":..} with the player's name. // This only matches votes cast WITHOUT Steam sign-in, so a Steam vote can // never be claimed by somebody who copies the voter's name. using System; using System.Collections.Generic; using System.Text; using System.Text.RegularExpressions; using Newtonsoft.Json; using Oxide.Core.Libraries; namespace Oxide.Plugins { [Info("VoxelRank", "VoxelRank", "1.0.0")] [Description("Vote rewards for VoxelRank: /vote shows the vote link, /claim gives the reward.")] public class VoxelRank : RustPlugin { #region Config private PluginConfig config; private class ItemReward { [JsonProperty("Item short name")] public string ShortName = "scrap"; [JsonProperty("Amount")] public int Amount = 50; [JsonProperty("Skin ID (0 = none)")] public ulong Skin = 0; } private class PluginConfig { [JsonProperty("API key (from your listing's edit page)")] public string ApiKey = ""; [JsonProperty("Site URL")] public string SiteUrl = "https://voxelrank.com"; [JsonProperty("Vote page URL (empty = ask the site)")] public string VoteUrl = ""; [JsonProperty("Seconds a player must wait between /claim uses")] public double ClaimCooldown = 15; [JsonProperty("Accept votes cast with a typed name (players without Steam sign-in)")] public bool AllowNameVotes = true; [JsonProperty("Tell the whole server when someone claims")] public bool Broadcast = true; [JsonProperty("Console commands per vote ({steamid} and {name} are replaced)")] public List Commands = new List(); [JsonProperty("Items per vote")] public List Items = new List(); public static PluginConfig Defaults() { return new PluginConfig { Commands = new List(), Items = new List { new ItemReward { ShortName = "scrap", Amount = 50 } }, }; } } protected override void LoadDefaultConfig() { config = PluginConfig.Defaults(); } protected override void LoadConfig() { base.LoadConfig(); try { config = Config.ReadObject(); if (config == null) throw new Exception("empty config"); if (config.Commands == null) config.Commands = new List(); if (config.Items == null) config.Items = new List(); } catch (Exception e) { PrintError($"The config file could not be read ({e.Message}). Using the defaults; fix the file and reload."); LoadDefaultConfig(); return; } SaveConfig(); } protected override void SaveConfig() { Config.WriteObject(config, true); } #endregion #region Lang protected override void LoadDefaultMessages() { lang.RegisterMessages(new Dictionary { ["VoteLink"] = "Vote for us here: {0}\nThen type /claim to get your reward.", ["VoteLinkUnknown"] = "The vote link is not set up yet. Ask an admin.", ["NotConfigured"] = "Vote rewards are not set up yet. Ask an admin.", ["Checking"] = "Checking your vote...", ["Cooldown"] = "Please wait {0} seconds before trying again.", ["Busy"] = "Still checking your last request, one moment.", ["NotVoted"] = "We could not find a vote from you in the last 24 hours. Type /vote for the link.", ["AlreadyClaimed"] = "You already got the reward for your last vote. Type /vote to see when you can vote again.", ["Rewarded"] = "Thanks for voting! You got your reward.", ["RewardedMany"] = "Thanks for voting! You got the reward for {0} votes.", ["Broadcast"] = "{0} voted for the server and got a reward. Type /vote to do the same!", ["ApiError"] = "The vote site did not answer. Please try again in a minute.", }, this); } private string Msg(string key, string userId, params object[] args) { var text = lang.GetMessage(key, this, userId); return args.Length > 0 ? string.Format(text, args) : text; } private void Reply(BasePlayer player, string key, params object[] args) { if (player == null || !player.IsConnected) return; SendReply(player, Msg(key, player.UserIDString, args)); } #endregion #region State // Per player: when /claim was last used, and whether a request is in flight. private readonly Dictionary lastClaim = new Dictionary(); private readonly HashSet inFlight = new HashSet(); // Asked from the site on start when the config leaves it empty. private string voteUrl = ""; // The site accepts typed names of 3-16 letters, digits or underscores. private static readonly Regex VoteName = new Regex("^[A-Za-z0-9_]{3,16}$"); private class CheckResponse { [JsonProperty("status")] public int Status; } private class ServerResponse { [JsonProperty("vote_url")] public string VoteUrl; } private class ClaimResponse { [JsonProperty("claimed")] public int Claimed; [JsonProperty("status")] public int Status; } #endregion #region Hooks private void OnServerInitialized() { if (string.IsNullOrEmpty(config.ApiKey)) { PrintWarning("No API key set. Paste your listing's API key into the config and reload."); return; } voteUrl = config.VoteUrl ?? ""; if (voteUrl.Length > 0) return; Request(RequestMethod.GET, "/api/v1/server", null, (code, body) => { if (code != 200) { PrintWarning($"Could not read the vote link from the site (HTTP {code})."); return; } var s = Parse(body); if (s != null && !string.IsNullOrEmpty(s.VoteUrl)) voteUrl = s.VoteUrl; }); } private void Unload() { inFlight.Clear(); lastClaim.Clear(); } #endregion #region Commands [ChatCommand("vote")] private void CmdVote(BasePlayer player, string command, string[] args) { if (string.IsNullOrEmpty(voteUrl)) Reply(player, "VoteLinkUnknown"); else Reply(player, "VoteLink", voteUrl); } [ChatCommand("claim")] private void CmdClaim(BasePlayer player, string command, string[] args) { if (player == null) return; if (string.IsNullOrEmpty(config.ApiKey)) { Reply(player, "NotConfigured"); return; } var id = player.UserIDString; if (inFlight.Contains(id)) { Reply(player, "Busy"); return; } DateTime last; if (lastClaim.TryGetValue(id, out last)) { var wait = config.ClaimCooldown - (DateTime.UtcNow - last).TotalSeconds; if (wait > 0) { Reply(player, "Cooldown", Math.Ceiling(wait)); return; } } lastClaim[id] = DateTime.UtcNow; inFlight.Add(id); Reply(player, "Checking"); // Copied now: the player can disconnect before the answer arrives. var name = player.displayName ?? ""; Request(RequestMethod.GET, "/api/v1/votes/check?steamid=" + Uri.EscapeDataString(id), null, (code, body) => { if (code != 200) { Done(id); Reply(player, "ApiError"); LogHttp("check", code, body); return; } var check = Parse(body); if (check == null) { Done(id); Reply(player, "ApiError"); return; } if (check.Status == 1) { Claim(player, id, name, "steamid", id, check.Status); return; } // No unclaimed Steam vote. The player may have typed their name instead. if (config.AllowNameVotes && VoteName.IsMatch(name)) { Claim(player, id, name, "username", name, check.Status); return; } Done(id); Reply(player, check.Status == 2 ? "AlreadyClaimed" : "NotVoted"); }); } #endregion #region Claim and rewards // steamStatus: what the steamid check said, so a name claim that finds // nothing still says "already claimed" when the Steam vote was. private void Claim(BasePlayer player, string id, string name, string field, string value, int steamStatus) { var body = JsonConvert.SerializeObject(new Dictionary { [field] = value }); Request(RequestMethod.POST, "/api/v1/votes/claim", body, (code, response) => { Done(id); if (code != 200) { Reply(player, "ApiError"); LogHttp("claim", code, response); return; } var claim = Parse(response); if (claim == null) { Reply(player, "ApiError"); return; } if (claim.Claimed > 0) { GiveRewards(player, id, name, claim.Claimed); return; } Reply(player, claim.Status == 2 || steamStatus == 2 ? "AlreadyClaimed" : "NotVoted"); }); } private void GiveRewards(BasePlayer player, string id, string name, int votes) { // A name goes into console commands, so it must not be able to end // the command or start a new one. var safeName = CleanName(name); for (var v = 0; v < votes; v++) { foreach (var cmd in config.Commands) { if (string.IsNullOrWhiteSpace(cmd)) continue; ConsoleSystem.Run(ConsoleSystem.Option.Server, cmd.Replace("{steamid}", id).Replace("{name}", safeName)); } } if (player != null && player.IsConnected) { foreach (var reward in config.Items) { if (reward == null || string.IsNullOrEmpty(reward.ShortName) || reward.Amount <= 0) continue; var item = ItemManager.CreateByName(reward.ShortName, reward.Amount * votes, reward.Skin); if (item == null) { PrintWarning($"Unknown item short name in config: {reward.ShortName}"); continue; } player.GiveItem(item); } if (votes > 1) Reply(player, "RewardedMany", votes); else Reply(player, "Rewarded"); } else if (config.Items.Count > 0) { PrintWarning($"{safeName} ({id}) left before the item rewards could be given. The vote is claimed; give the items by hand if needed."); } if (config.Broadcast) { foreach (var p in BasePlayer.activePlayerList) { if (p != null && p.UserIDString != id) SendReply(p, Msg("Broadcast", p.UserIDString, safeName)); } } Puts($"{safeName} ({id}) claimed {votes} vote(s)."); } private static string CleanName(string name) { var sb = new StringBuilder(); foreach (var c in name ?? "") { if (char.IsControl(c) || c == '"' || c == '\'' || c == ';' || c == '\\') continue; sb.Append(c); } var s = sb.ToString().Trim(); return s.Length > 32 ? s.Substring(0, 32) : s; } #endregion #region HTTP private void Request(RequestMethod method, string path, string body, Action callback) { var headers = new Dictionary { ["X-Api-Key"] = config.ApiKey, ["User-Agent"] = "VoxelRank-Rust/" + Version, ["Accept"] = "application/json", }; if (body != null) headers["Content-Type"] = "application/json"; var url = (config.SiteUrl ?? "").TrimEnd('/') + path; webrequest.Enqueue(url, body, (code, response) => { try { callback(code, response); } catch (Exception e) { PrintError($"Handling the answer from {path} failed: {e.Message}"); } }, this, method, headers, 15f); } private void Done(string id) { inFlight.Remove(id); } private T Parse(string body) where T : class { if (string.IsNullOrEmpty(body)) return null; try { return JsonConvert.DeserializeObject(body); } catch { return null; } } private void LogHttp(string what, int code, string body) { var text = body ?? ""; if (text.Length > 200) text = text.Substring(0, 200); PrintWarning($"VoxelRank {what} failed: HTTP {code} {text}"); } #endregion } }