using System; using System.Collections.Generic; using System.Linq; using System.Net; using Newtonsoft.Json; using Oxide.Core; using Oxide.Core.Libraries; using Oxide.Core.Plugins; namespace Oxide.Plugins { [Info("WUQI", "RustAdmin", "1.0.1")] [Description("将 Rust 聊天、服务器日志和 Economics 余额上传到 RustAdmin,不保存任何数据库连接信息。")] public class WUQI : RustPlugin { [PluginReference] private Plugin Economics; private WUQIConfig config; private Timer economyTimer; private Timer economyQueueTimer; private Timer allianceBanUploadTimer; private readonly Queue uploadQueue = new Queue(); private readonly HashSet queuedKeys = new HashSet(); private readonly HashSet uploadedLocalBans = new HashSet(); private readonly HashSet allianceBanWhitelist = new HashSet(); private readonly Dictionary lastUploadedEconomyBalances = new Dictionary(); private readonly Dictionary lastEconomyUploadAt = new Dictionary(); private double nextFailureLogAt; private double nextSuccessLogAt; private int economyCursor; private class WUQIConfig { public string SiteUrl = "https://your-panel.example.com"; public string ServerId = ""; public string UploadToken = ""; public float EconomyUploadIntervalSeconds = 60f; public int EconomyPlayersPerSnapshot = 200; public float EconomyUploadSpacingSeconds = 0.35f; public bool EconomyUploadOnlyChanged = true; public float EconomyForceResyncHours = 6f; public double EconomyBalanceChangeEpsilon = 0.001d; public float AllianceBanUploadIntervalSeconds = 120f; public float UploadTimeoutSeconds = 4f; public float FailureLogCooldownSeconds = 60f; } protected override void LoadDefaultConfig() { config = new WUQIConfig(); SaveConfig(); } protected override void LoadConfig() { base.LoadConfig(); try { config = Config.ReadObject(); if (config == null) { throw new Exception("empty config"); } } catch { PrintWarning("配置文件无效,已重新生成默认 WUQI 配置。"); LoadDefaultConfig(); } } protected override void SaveConfig() { Config.WriteObject(config, true); } private void OnServerInitialized() { LoadAllianceBanWhitelist(); StartEconomyTimer(); StartQueueTimer(); StartAllianceBanUploadTimer(); Puts("WUQI 已启动:聊天、服务器日志和 Economics 余额将上传到 RustAdmin。"); } [ConsoleCommand("wuqi.alliancewhite")] private void AllianceWhitelistCommand(ConsoleSystem.Arg arg) { if (arg == null || arg.Connection != null && arg.Connection.authLevel < 2) { return; } var action = (arg.GetString(0, "list") ?? "list").ToLowerInvariant(); var steamId = ExtractSteamId(arg.GetString(1, "")); if (action == "add") { if (steamId == null) { arg.ReplyWith("用法:wuqi.alliancewhite add 7656xxxxxxxxxxxxx"); return; } allianceBanWhitelist.Add(steamId); SaveAllianceBanWhitelist(); arg.ReplyWith($"已加入本服白名单:{steamId},本服务器将跳过该玩家的联盟封禁。"); return; } if (action == "remove") { if (steamId == null) { arg.ReplyWith("用法:wuqi.alliancewhite remove 7656xxxxxxxxxxxxx"); return; } allianceBanWhitelist.Remove(steamId); SaveAllianceBanWhitelist(); arg.ReplyWith($"已移除本服白名单:{steamId}"); return; } if (allianceBanWhitelist.Count == 0) { arg.ReplyWith("本服白名单为空。"); return; } arg.ReplyWith("本服白名单:" + string.Join(", ", allianceBanWhitelist)); } private void Unload() { economyTimer?.Destroy(); economyQueueTimer?.Destroy(); allianceBanUploadTimer?.Destroy(); } private object OnPlayerChat(BasePlayer player, string message, ConVar.Chat.ChatChannel channel) { if (player == null || string.IsNullOrWhiteSpace(message)) { return null; } EnqueueUpload("chat", player.UserIDString, new Dictionary { ["steam_id"] = player.UserIDString, ["name"] = player.displayName ?? "Unknown", ["message"] = message, ["channel"] = channel.ToString().ToLowerInvariant(), ["sent_at"] = DateTime.UtcNow.ToString("o") }); return null; } private void OnServerMessage(string message, string name, string color, ulong id) { if (string.IsNullOrWhiteSpace(message)) { return; } EnqueueUpload("log", "server:" + DateTime.UtcNow.Ticks, new Dictionary { ["level"] = "info", ["source"] = string.IsNullOrWhiteSpace(name) ? "server" : name, ["message"] = message, ["logged_at"] = DateTime.UtcNow.ToString("o") }); } private void OnPlayerConnected(BasePlayer player) { if (player == null) { return; } UploadPlayerLifecycle("join", player); CheckAllianceBanOnJoin(player); } private void OnPlayerDisconnected(BasePlayer player, string reason) { if (player == null) { return; } UploadPlayerLifecycle("leave", player, reason); } private void UploadPlayerLifecycle(string action, BasePlayer player, string reason = "") { var actionText = action == "join" ? "进入服务器" : "离开服务器"; EnqueueUpload("log", action + ":" + player.UserIDString + ":" + DateTime.UtcNow.Ticks, new Dictionary { ["level"] = "player", ["source"] = "lifecycle", ["message"] = $"{player.displayName} ({player.UserIDString}) {actionText} {reason}".Trim(), ["logged_at"] = DateTime.UtcNow.ToString("o") }); } private void OnRconCommand(IPAddress ip, string command, string[] args) { // Do not upload operator command text to the site log. } private void StartEconomyTimer() { economyTimer?.Destroy(); var interval = Math.Max(30f, config.EconomyUploadIntervalSeconds); economyTimer = timer.Every(interval, UploadEconomySnapshot); } private void StartQueueTimer() { economyQueueTimer?.Destroy(); var spacing = Math.Max(0.2f, config.EconomyUploadSpacingSeconds); economyQueueTimer = timer.Every(spacing, FlushNextUpload); } private void StartAllianceBanUploadTimer() { allianceBanUploadTimer?.Destroy(); var interval = Math.Max(60f, config.AllianceBanUploadIntervalSeconds); allianceBanUploadTimer = timer.Every(interval, UploadNewLocalBans); timer.Once(10f, UploadNewLocalBans); } private void CheckAllianceBanOnJoin(BasePlayer player) { if (player == null || !IsConfigured()) { return; } var steamId = ExtractSteamId(player.UserIDString); if (steamId == null || allianceBanWhitelist.Contains(steamId)) { return; } var url = $"{config.SiteUrl.TrimEnd('/')}/?plugin=alliance-ban-check"; var body = JsonConvert.SerializeObject(new Dictionary { ["server_id"] = config.ServerId, ["upload_token"] = config.UploadToken, ["steam_id"] = steamId }); var headers = new Dictionary { ["Content-Type"] = "application/json", ["User-Agent"] = "WUQI-Oxide/1.0" }; webrequest.Enqueue(url, body, (code, response) => { if (code < 200 || code >= 300 || string.IsNullOrWhiteSpace(response)) { LogUploadFailure(code, response); return; } ApplyAllianceBanCheckResponse(player, steamId, response); }, this, RequestMethod.POST, headers, Math.Max(2f, config.UploadTimeoutSeconds)); } private void UploadNewLocalBans() { foreach (var serverUser in ServerUsers.GetAll(ServerUsers.UserGroup.Banned)) { if (serverUser == null) { continue; } var steamId = ExtractSteamId(serverUser.steamid.ToString()); if (steamId == null || uploadedLocalBans.Contains(steamId)) { continue; } uploadedLocalBans.Add(steamId); EnqueueUpload("alliance-ban", "local-ban:" + steamId, new Dictionary { ["steam_id"] = steamId, ["name"] = ResolveLocalBanName(steamId), ["reason"] = ResolveLocalBanReason(serverUser.notes, serverUser.username, steamId), ["created_at"] = DateTime.UtcNow.ToString("o") }); } } private string ResolveLocalBanName(string steamId) { foreach (var player in BasePlayer.activePlayerList.Concat(BasePlayer.sleepingPlayerList)) { if (player != null && player.UserIDString == steamId && !string.IsNullOrWhiteSpace(player.displayName)) { return player.displayName; } } foreach (var knownPlayer in covalence.Players.All) { if (knownPlayer != null && knownPlayer.Id == steamId && !string.IsNullOrWhiteSpace(knownPlayer.Name)) { return knownPlayer.Name; } } return steamId; } private string ResolveLocalBanReason(string notes, string username, string steamId) { if (!string.IsNullOrWhiteSpace(notes)) { return notes; } if (!string.IsNullOrWhiteSpace(username) && username != steamId) { return username; } return "server ban"; } private void ApplyAllianceBanCheckResponse(BasePlayer player, string steamId, string response) { AllianceBanCheckResponse payload = null; try { payload = JsonConvert.DeserializeObject(response); } catch (Exception exception) { PrintWarning($"联盟禁令检查响应解析失败:{exception.Message}"); return; } if (payload == null || !payload.ok || !payload.banned) { return; } if (player == null || !player.IsConnected || allianceBanWhitelist.Contains(steamId)) { return; } var reason = string.IsNullOrWhiteSpace(payload.reason) ? "alliance ban" : payload.reason.Replace("\"", ""); player.Kick($"联盟封禁:{reason}"); LogUploadInfo($"已拒绝联盟封禁玩家进服:{steamId}。", true); } private void LoadAllianceBanWhitelist() { allianceBanWhitelist.Clear(); try { var saved = Interface.Oxide.DataFileSystem.ReadObject>("WUQI_AllianceBanWhitelist") ?? new List(); foreach (var steamId in saved.Select(ExtractSteamId).Where(id => id != null)) { allianceBanWhitelist.Add(steamId); } } catch { SaveAllianceBanWhitelist(); } } private void SaveAllianceBanWhitelist() { Interface.Oxide.DataFileSystem.WriteObject("WUQI_AllianceBanWhitelist", allianceBanWhitelist.OrderBy(id => id).ToList()); } private void UploadEconomySnapshot() { if (Economics == null) { LogUploadInfo("未检测到 Economics 插件,本轮余额上传已跳过。", true); return; } var players = CollectKnownEconomyPlayers().ToList(); if (players.Count == 0) { LogUploadInfo("未找到 Economics 玩家数据,本轮余额上传已跳过。", true); return; } var limit = Math.Min(players.Count, Math.Max(20, config.EconomyPlayersPerSnapshot)); var queued = 0; for (var offset = 0; offset < limit; offset++) { var player = players[(economyCursor + offset) % players.Count]; var balance = ReadBalance(player.SteamId); if (balance == null) { continue; } if (!ShouldUploadEconomyBalance(player.SteamId, balance.Value)) { continue; } EnqueueUpload("economy", player.SteamId, new Dictionary { ["steam_id"] = player.SteamId, ["name"] = player.Name, ["balance"] = balance.Value, ["captured_at"] = DateTime.UtcNow.ToString("o") }); queued++; } economyCursor = (economyCursor + limit) % players.Count; LogUploadInfo($"已加入余额上传队列:{queued}/{players.Count} 名玩家,队列剩余 {uploadQueue.Count}。", true); } private IEnumerable CollectKnownEconomyPlayers() { var players = new Dictionary(); foreach (var stored in ReadEconomicsDataFilePlayers()) { players[stored.SteamId] = stored; } foreach (var knownPlayer in covalence.Players.All) { if (IsSteamId(knownPlayer.Id) && !players.ContainsKey(knownPlayer.Id)) { players[knownPlayer.Id] = new EconomyPlayer(knownPlayer.Id, knownPlayer.Name ?? knownPlayer.Id); } } foreach (var player in BasePlayer.activePlayerList.Concat(BasePlayer.sleepingPlayerList)) { if (player == null || !IsSteamId(player.UserIDString)) { continue; } players[player.UserIDString] = new EconomyPlayer(player.UserIDString, player.displayName ?? player.UserIDString); } return players.Values; } private List ReadEconomicsDataFilePlayers() { var players = new List(); Dictionary balances = null; try { balances = Interface.Oxide.DataFileSystem.ReadObject>("Economics"); } catch { return players; } if (balances == null) { return players; } foreach (var entry in balances) { var steamId = ExtractSteamId(entry.Key); if (steamId == null) { steamId = ExtractSteamIdFromValue(entry.Value); } if (steamId != null) { players.Add(new EconomyPlayer(steamId, steamId)); } } return players; } private string ExtractSteamIdFromValue(object value) { if (value == null) { return null; } if (value is IDictionary dictionary) { foreach (var key in new[] { "userid", "userId", "steamid", "steamId", "id" }) { if (dictionary.TryGetValue(key, out var nested)) { var steamId = ExtractSteamId(nested?.ToString()); if (steamId != null) { return steamId; } } } } return ExtractSteamId(value.ToString()); } private string ExtractSteamId(string value) { if (string.IsNullOrWhiteSpace(value)) { return null; } var trimmed = value.Trim(); return IsSteamId(trimmed) ? trimmed : null; } private bool IsSteamId(string value) { return !string.IsNullOrWhiteSpace(value) && value.Length == 17 && value.StartsWith("7656"); } private double? ReadBalance(string steamId) { object result = null; try { result = Economics.Call("Balance", steamId); if (result == null) { result = Economics.Call("Balance", Convert.ToUInt64(steamId)); } } catch (Exception exception) { PrintWarning($"读取玩家 {steamId} 的 Economics 余额失败:{exception.Message}"); } if (result == null) { return null; } if (double.TryParse(result.ToString(), out var balance)) { return balance; } return null; } private bool ShouldUploadEconomyBalance(string steamId, double balance) { if (!config.EconomyUploadOnlyChanged) { return true; } if (!lastUploadedEconomyBalances.TryGetValue(steamId, out var uploadedBalance)) { return true; } var epsilon = Math.Max(0d, config.EconomyBalanceChangeEpsilon); if (Math.Abs(uploadedBalance - balance) > epsilon) { return true; } var forceResyncHours = config.EconomyForceResyncHours; if (forceResyncHours <= 0f) { return false; } if (!lastEconomyUploadAt.TryGetValue(steamId, out var uploadedAt)) { return true; } return Interface.Oxide.Now - uploadedAt >= forceResyncHours * 3600d; } private void MarkEconomyUploaded(Dictionary payload) { if (payload == null || !payload.TryGetValue("steam_id", out var steamIdValue) || !payload.TryGetValue("balance", out var balanceValue)) { return; } var steamId = ExtractSteamId(steamIdValue?.ToString()); var balance = ReadPayloadDouble(balanceValue); if (steamId == null || balance == null) { return; } lastUploadedEconomyBalances[steamId] = balance.Value; lastEconomyUploadAt[steamId] = Interface.Oxide.Now; } private double? ReadPayloadDouble(object value) { if (value == null) { return null; } if (value is double doubleValue) { return doubleValue; } if (value is float floatValue) { return floatValue; } if (value is decimal decimalValue) { return (double)decimalValue; } if (value is int intValue) { return intValue; } if (value is long longValue) { return longValue; } if (double.TryParse(value.ToString(), out var parsed)) { return parsed; } return null; } private class EconomyPlayer { public EconomyPlayer(string steamId, string name) { SteamId = steamId; Name = string.IsNullOrWhiteSpace(name) ? steamId : name; } public string SteamId { get; } public string Name { get; } } private void EnqueueUpload(string endpoint, string key, Dictionary payload) { if (!IsConfigured()) { return; } payload["server_id"] = config.ServerId; payload["upload_token"] = config.UploadToken; var queueKey = endpoint + ":" + key; if (queuedKeys.Contains(queueKey)) { return; } queuedKeys.Add(queueKey); uploadQueue.Enqueue(new UploadJob(endpoint, queueKey, payload)); } private void FlushNextUpload() { if (uploadQueue.Count == 0) { return; } var job = uploadQueue.Dequeue(); queuedKeys.Remove(job.QueueKey); var url = $"{config.SiteUrl.TrimEnd('/')}/?plugin={job.Endpoint}"; var body = JsonConvert.SerializeObject(job.Payload); var headers = new Dictionary { ["Content-Type"] = "application/json", ["User-Agent"] = "WUQI-Oxide/1.0" }; webrequest.Enqueue(url, body, (code, response) => { if (code < 200 || code >= 300) { LogUploadFailure(code, response); return; } if (job.Endpoint == "economy") { MarkEconomyUploaded(job.Payload); } LogUploadSuccess(job.Endpoint); }, this, RequestMethod.POST, headers, Math.Max(2f, config.UploadTimeoutSeconds)); } private void LogUploadSuccess(string endpoint) { LogUploadInfo($"已上传 {EndpointName(endpoint)} 数据到 RustAdmin,队列剩余 {uploadQueue.Count}。", false); } private void LogUploadFailure(int code, string response) { var now = Interface.Oxide.Now; if (now < nextFailureLogAt) { return; } nextFailureLogAt = now + Math.Max(15f, config.FailureLogCooldownSeconds); PrintWarning($"上传到 RustAdmin 失败:HTTP {code} {response}。当前队列 {uploadQueue.Count} 条,失败提示已限速。"); } private void LogUploadInfo(string message, bool respectCooldown) { var now = Interface.Oxide.Now; if (respectCooldown && now < nextSuccessLogAt) { return; } nextSuccessLogAt = now + Math.Max(15f, config.FailureLogCooldownSeconds); Puts(message); } private string EndpointName(string endpoint) { if (endpoint == "chat") { return "聊天记录"; } if (endpoint == "economy") { return "玩家余额"; } if (endpoint == "log") { return "服务器日志"; } return endpoint; } private bool IsConfigured() { if (!string.IsNullOrWhiteSpace(config.SiteUrl) && !string.IsNullOrWhiteSpace(config.ServerId) && !string.IsNullOrWhiteSpace(config.UploadToken)) { return true; } PrintWarning("请先在 oxide/config/WUQI.json 填写 SiteUrl、ServerId 和 UploadToken。"); return false; } private class UploadJob { public UploadJob(string endpoint, string queueKey, Dictionary payload) { Endpoint = endpoint; QueueKey = queueKey; Payload = payload; } public string Endpoint { get; } public string QueueKey { get; } public Dictionary Payload { get; } } private class AllianceBanCheckResponse { public bool ok { get; set; } public bool banned { get; set; } public string status { get; set; } public string reason { get; set; } } } }