using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Oxide.Core; using Oxide.Core.Libraries; using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace Oxide.Plugins { [Info("RUNWhitelist", "R.U.N.", "1.0.0")] [Description("Steam friend-aware whitelist system")] class RUNWhitelist : RustPlugin { #region Fields private PluginConfig _config; private StoredData _data; private HashSet _allowedSteamIds = new HashSet(); private HashSet _additionalAllowedSteamIds = new HashSet(); private Dictionary _friendsCache = new Dictionary(); private HashSet _effectiveFriendSet = new HashSet(); private bool _refreshInProgress; private Timer _refreshTimer; private TimeSpan _displayOffset; #endregion #region Configuration private class PluginConfig { [JsonProperty("General")] public GeneralSettings General { get; set; } = new GeneralSettings(); [JsonProperty("AccessControl")] public AccessControlSettings AccessControl { get; set; } = new AccessControlSettings(); [JsonProperty("SteamApi")] public SteamApiSettings SteamApi { get; set; } = new SteamApiSettings(); [JsonProperty("Cache")] public CacheSettings Cache { get; set; } = new CacheSettings(); [JsonProperty("Commands")] public CommandSettings Commands { get; set; } = new CommandSettings(); } private class GeneralSettings { [JsonProperty("Enabled")] public bool Enabled { get; set; } = true; [JsonProperty("LogAllowedConnections")] public bool LogAllowedConnections { get; set; } = true; [JsonProperty("LogDeniedConnections")] public bool LogDeniedConnections { get; set; } = true; [JsonProperty("LogApiCalls")] public bool LogApiCalls { get; set; } = false; [JsonProperty("LogCacheRefresh")] public bool LogCacheRefresh { get; set; } = true; [JsonProperty("TimezoneOffsetHours")] public double TimezoneOffsetHours { get; set; } = 0; } private class AccessControlSettings { [JsonProperty("BypassPermission")] public string BypassPermission { get; set; } = "runwhitelist.bypass"; [JsonProperty("AllowedSteamIds")] public List AllowedSteamIds { get; set; } = new List(); [JsonProperty("AdditionalAllowedSteamIds")] public List AdditionalAllowedSteamIds { get; set; } = new List(); [JsonProperty("EnableFriendCheck")] public bool EnableFriendCheck { get; set; } = true; [JsonProperty("FailClosedOnApiError")] public bool FailClosedOnApiError { get; set; } = true; [JsonProperty("DenyMessage")] public string DenyMessage { get; set; } = "This server is whitelisted. You are not authorized to join."; } private class SteamApiSettings { [JsonProperty("ApiKey")] public string ApiKey { get; set; } = ""; [JsonProperty("RequestTimeoutSeconds")] public int RequestTimeoutSeconds { get; set; } = 10; [JsonProperty("RefreshIntervalMinutes")] public int RefreshIntervalMinutes { get; set; } = 30; [JsonProperty("RetryCooldownMinutes")] public int RetryCooldownMinutes { get; set; } = 5; [JsonProperty("CacheFriendsToDataFile")] public bool CacheFriendsToDataFile { get; set; } = true; } private class CacheSettings { [JsonProperty("UseWarmCacheOnStartup")] public bool UseWarmCacheOnStartup { get; set; } = true; [JsonProperty("FullRefreshOnServerInitialized")] public bool FullRefreshOnServerInitialized { get; set; } = true; [JsonProperty("AllowJoinWhileRefreshInProgress")] public bool AllowJoinWhileRefreshInProgress { get; set; } = true; } private class CommandSettings { [JsonProperty("ChatCommand")] public string ChatCommand { get; set; } = "wl"; [JsonProperty("ConsoleCommand")] public string ConsoleCommand { get; set; } = "wl"; } protected override void LoadDefaultConfig() { _config = new PluginConfig(); SaveConfig(); } protected override void LoadConfig() { base.LoadConfig(); try { _config = Config.ReadObject() ?? new PluginConfig(); } catch (Exception ex) { PrintError($"Config load failed: {ex.Message}. Using defaults."); _config = new PluginConfig(); } SaveConfig(); } protected override void SaveConfig() => Config.WriteObject(_config); #endregion #region Data private class StoredData { [JsonProperty("FriendsCache")] public Dictionary FriendsCache { get; set; } = new Dictionary(); } private class FriendCacheEntry { [JsonProperty("LastSuccessUnix")] public long LastSuccessUnix { get; set; } [JsonProperty("LastAttemptUnix")] public long LastAttemptUnix { get; set; } [JsonProperty("IsPrivateOrUnauthorized")] public bool IsPrivateOrUnauthorized { get; set; } [JsonProperty("FriendSteamIds")] public List FriendSteamIds { get; set; } = new List(); } private void LoadData() { try { _data = Interface.Oxide.DataFileSystem.ReadObject(Name) ?? new StoredData(); } catch (Exception ex) { PrintError($"Data file load failed: {ex.Message}. Starting with empty cache."); _data = new StoredData(); } } private void SaveDataFile() { try { if (_config.SteamApi.CacheFriendsToDataFile) Interface.Oxide.DataFileSystem.WriteObject(Name, _data); } catch (Exception ex) { PrintError($"Data file save failed: {ex.Message}"); } } #endregion #region Lifecycle private void Init() { permission.RegisterPermission(_config.AccessControl.BypassPermission, this); cmd.AddChatCommand(_config.Commands.ChatCommand, this, nameof(WlChatCommand)); } private void OnServerInitialized() { _displayOffset = TimeSpan.FromHours(_config.General.TimezoneOffsetHours); LoadData(); NormalizeSteamIds(); if (_config.Cache.UseWarmCacheOnStartup) RestoreWarmCache(); RebuildEffectiveFriendSet(); if (_config.Cache.FullRefreshOnServerInitialized && _config.AccessControl.EnableFriendCheck) RefreshAllFriends(); if (_config.SteamApi.RefreshIntervalMinutes > 0 && _config.AccessControl.EnableFriendCheck) _refreshTimer = timer.Every(_config.SteamApi.RefreshIntervalMinutes * 60f, () => RefreshAllFriends()); Puts($"Initialized – Direct whitelist: {_allowedSteamIds.Count + _additionalAllowedSteamIds.Count} IDs, " + $"Friend check: {(_config.AccessControl.EnableFriendCheck ? "ON" : "OFF")}, " + $"Warm cache owners: {_friendsCache.Count}"); } private void Unload() { _refreshTimer?.Destroy(); SyncCacheToData(); SaveDataFile(); } #endregion #region Normalization & Warm Cache private void NormalizeSteamIds() { _allowedSteamIds = ParseSteamIdList(_config.AccessControl.AllowedSteamIds, "AllowedSteamIds"); _additionalAllowedSteamIds = ParseSteamIdList(_config.AccessControl.AdditionalAllowedSteamIds, "AdditionalAllowedSteamIds"); } private HashSet ParseSteamIdList(List source, string label) { var set = new HashSet(); if (source == null) return set; foreach (var raw in source) { if (ulong.TryParse(raw?.Trim(), out var id) && id > 76561190000000000UL) set.Add(id); else if (!string.IsNullOrWhiteSpace(raw)) PrintWarning($"Invalid SteamID in {label}: '{raw}'"); } return set; } private void RestoreWarmCache() { _friendsCache.Clear(); if (_data?.FriendsCache == null) return; foreach (var kvp in _data.FriendsCache) { if (ulong.TryParse(kvp.Key, out var ownerId)) _friendsCache[ownerId] = kvp.Value ?? new FriendCacheEntry(); } LogIf(_config.General.LogCacheRefresh, $"Warm cache restored: {_friendsCache.Count} owner(s)"); } #endregion #region Connection Hook private object CanUserLogin(string name, string id, string ipAddress) { if (!_config.General.Enabled) return null; if (!ulong.TryParse(id, out var steamId)) return null; if (IsBypassed(id, steamId)) { LogIf(_config.General.LogAllowedConnections, $"[ALLOW:BYPASS] {name} ({id})"); return null; } if (IsDirectAllowed(steamId)) { LogIf(_config.General.LogAllowedConnections, $"[ALLOW:DIRECT] {name} ({id})"); return null; } if (!_config.AccessControl.EnableFriendCheck) { LogIf(_config.General.LogDeniedConnections, $"[DENY] {name} ({id}) – friend check disabled, not in direct list"); return _config.AccessControl.DenyMessage; } if (_refreshInProgress && !_config.Cache.AllowJoinWhileRefreshInProgress) { LogIf(_config.General.LogDeniedConnections, $"[DENY:REFRESH_IN_PROGRESS] {name} ({id})"); return _config.AccessControl.DenyMessage; } if (IsFriendAllowed(steamId)) { LogIf(_config.General.LogAllowedConnections, $"[ALLOW:FRIEND] {name} ({id})"); return null; } LogIf(_config.General.LogDeniedConnections, $"[DENY] {name} ({id})"); return _config.AccessControl.DenyMessage; } #endregion #region Access Check Methods private bool IsBypassed(string idStr, ulong steamId) { return ServerUsers.Is(steamId, ServerUsers.UserGroup.Owner) || permission.UserHasPermission(idStr, _config.AccessControl.BypassPermission); } private bool IsDirectAllowed(ulong steamId) { return _allowedSteamIds.Contains(steamId) || _additionalAllowedSteamIds.Contains(steamId); } private bool IsFriendAllowed(ulong steamId) { return _effectiveFriendSet.Contains(steamId); } #endregion #region Effective Friend Set private void RebuildEffectiveFriendSet() { var newSet = new HashSet(); foreach (var ownerId in GetAllOwnerIds()) { if (!_friendsCache.TryGetValue(ownerId, out var entry)) continue; if (entry?.FriendSteamIds == null) continue; foreach (var fid in entry.FriendSteamIds) newSet.Add(fid); } _effectiveFriendSet = newSet; } private HashSet GetAllOwnerIds() { var all = new HashSet(_allowedSteamIds); foreach (var id in _additionalAllowedSteamIds) all.Add(id); return all; } #endregion #region Steam API private void RefreshAllFriends() { if (_refreshInProgress) { LogIf(_config.General.LogCacheRefresh, "Refresh already in progress, skipping."); return; } if (string.IsNullOrWhiteSpace(_config.SteamApi.ApiKey)) { PrintWarning("Steam API key not configured. Skipping friend list refresh."); return; } var owners = GetAllOwnerIds().ToList(); if (owners.Count == 0) { LogIf(_config.General.LogCacheRefresh, "No owner SteamIDs configured. Nothing to refresh."); return; } _refreshInProgress = true; LogIf(_config.General.LogCacheRefresh, $"Starting friend list refresh for {owners.Count} owner(s)..."); RefreshOwnerSequential(owners, 0); } // Sequential processing avoids hammering the Steam API with concurrent requests. private void RefreshOwnerSequential(List owners, int index) { if (index >= owners.Count) { RebuildEffectiveFriendSet(); SyncCacheToData(); SaveDataFile(); _refreshInProgress = false; LogIf(_config.General.LogCacheRefresh, $"Friend list refresh complete. Effective friend set: {_effectiveFriendSet.Count} unique IDs."); return; } FetchFriendList(owners[index], () => RefreshOwnerSequential(owners, index + 1)); } private void FetchFriendList(ulong ownerId, Action onComplete) { if (string.IsNullOrWhiteSpace(_config.SteamApi.ApiKey)) { onComplete?.Invoke(); return; } if (!_friendsCache.TryGetValue(ownerId, out var entry)) { entry = new FriendCacheEntry(); _friendsCache[ownerId] = entry; } long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); long cooldown = (long)_config.SteamApi.RetryCooldownMinutes * 60L; // Suppress retry if last attempt failed within cooldown window if (entry.LastAttemptUnix > 0 && entry.LastSuccessUnix < entry.LastAttemptUnix && (now - entry.LastAttemptUnix) < cooldown) { LogIf(_config.General.LogApiCalls, $"[API] Skipping {ownerId}: retry cooldown active"); onComplete?.Invoke(); return; } entry.LastAttemptUnix = now; var url = "https://api.steampowered.com/ISteamUser/GetFriendList/v1/" + $"?key={_config.SteamApi.ApiKey}&steamid={ownerId}&relationship=friend"; LogIf(_config.General.LogApiCalls, $"[API] Requesting friend list for {ownerId}..."); try { webrequest.Enqueue(url, null, (code, body) => { try { ProcessFriendListResponse(ownerId, code, body); } catch (Exception ex) { PrintError($"[API] Exception processing response for {ownerId}: {ex.Message}"); } finally { onComplete?.Invoke(); } }, this, RequestMethod.GET, null, _config.SteamApi.RequestTimeoutSeconds); } catch (Exception ex) { PrintError($"[API] Failed to enqueue request for {ownerId}: {ex.Message}"); onComplete?.Invoke(); } } private void ProcessFriendListResponse(ulong ownerId, int code, string body) { if (!_friendsCache.TryGetValue(ownerId, out var entry)) { entry = new FriendCacheEntry(); _friendsCache[ownerId] = entry; } if (code == 401) { entry.IsPrivateOrUnauthorized = true; PrintWarning($"[API] Owner {ownerId}: HTTP 401 – friend list is private or API key unauthorized"); return; } if (code != 200 || string.IsNullOrEmpty(body)) { PrintWarning($"[API] Owner {ownerId}: HTTP {code} – request failed"); return; } try { var json = JObject.Parse(body); var friends = json?["friendslist"]?["friends"] as JArray; if (friends == null) { PrintWarning($"[API] Owner {ownerId}: unexpected JSON structure (missing friendslist.friends)"); return; } var ids = new List(friends.Count); foreach (var f in friends) { if (ulong.TryParse(f?["steamid"]?.ToString(), out var fid)) ids.Add(fid); } entry.FriendSteamIds = ids; entry.LastSuccessUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); entry.IsPrivateOrUnauthorized = false; LogIf(_config.General.LogApiCalls, $"[API] Owner {ownerId}: {ids.Count} friend(s) retrieved"); } catch (JsonException ex) { PrintError($"[API] Owner {ownerId}: JSON parse error – {ex.Message}"); } } private void SyncCacheToData() { if (_data == null) _data = new StoredData(); _data.FriendsCache.Clear(); foreach (var kvp in _friendsCache) _data.FriendsCache[kvp.Key.ToString()] = kvp.Value; } #endregion #region Commands private void WlChatCommand(BasePlayer player, string command, string[] args) { if (!player.IsAdmin) { player.ChatMessage(L("NoPermission", player.UserIDString)); return; } DispatchCommand(msg => player.ChatMessage(msg), args, player.UserIDString); } [ConsoleCommand("wl")] private void WlConsoleCommand(ConsoleSystem.Arg arg) { var player = arg.Connection?.player as BasePlayer; string userId = player?.UserIDString; if (arg.Connection != null && !arg.IsAdmin) { arg.ReplyWith(L("NoPermission", userId)); return; } Action reply; if (arg.Connection == null) { reply = msg => Puts(msg); } else { reply = player != null ? (Action)(msg => player.SendConsoleCommand("echo", msg)) : msg => arg.ReplyWith(msg); } DispatchCommand(reply, arg.HasArgs() ? arg.Args : new string[0], userId); } private void DispatchCommand(Action reply, string[] args, string userId) { if (args == null || args.Length == 0) { ShowHelp(reply, userId); return; } try { switch (args[0].ToLower()) { case "status": CmdStatus(reply, userId); break; case "refresh": CmdRefresh(reply, args, userId); break; case "add": CmdAdd(reply, args, userId); break; case "remove": CmdRemove(reply, args, userId); break; case "check": CmdCheck(reply, args, userId); break; case "dump": CmdDump(reply, userId); break; default: ShowHelp(reply, userId); break; } } catch (Exception ex) { reply($"Error: {ex.Message}"); PrintError($"Command '{string.Join(" ", args)}' error: {ex}"); } } private void ShowHelp(Action reply, string userId) { var c = _config.Commands.ChatCommand; reply( $"{L("HelpHeader", userId)}\n" + $" /{c} status – {L("HelpStatus", userId)}\n" + $" /{c} refresh – {L("HelpRefresh", userId)}\n" + $" /{c} refresh – {L("HelpRefreshId", userId)}\n" + $" /{c} add – {L("HelpAdd", userId)}\n" + $" /{c} remove – {L("HelpRemove", userId)}\n" + $" /{c} check – {L("HelpCheck", userId)}\n" + $" /{c} dump – {L("HelpDump", userId)}"); } private void CmdStatus(Action reply, string userId) { var entries = new Dictionary(); foreach (var id in _allowedSteamIds) entries[id] = "allowed"; foreach (var id in _additionalAllowedSteamIds) if (!entries.ContainsKey(id)) entries[id] = "direct"; if (_config.AccessControl.EnableFriendCheck) { foreach (var id in _effectiveFriendSet) if (!entries.ContainsKey(id)) entries[id] = "friend"; } if (entries.Count == 0) { reply(L("StatusNoAccounts", userId)); return; } reply(L("StatusFetching", userId, entries.Count)); FetchPlayerSummaries(entries.Keys.ToList(), summaries => { RenderStatusTable(reply, entries, summaries, userId); }); } private class PlayerSummary { public string PersonaName; public long LastLogoff; } private void FetchPlayerSummaries(List steamIds, Action> callback) { if (string.IsNullOrWhiteSpace(_config.SteamApi.ApiKey) || steamIds.Count == 0) { callback?.Invoke(new Dictionary()); return; } var result = new Dictionary(); var batches = new List>(); for (int i = 0; i < steamIds.Count; i += 100) batches.Add(steamIds.GetRange(i, Math.Min(100, steamIds.Count - i))); FetchSummaryBatch(batches, 0, result, callback); } private void FetchSummaryBatch(List> batches, int index, Dictionary result, Action> callback) { if (index >= batches.Count) { callback?.Invoke(result); return; } var ids = string.Join(",", batches[index]); var url = "https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/" + $"?key={_config.SteamApi.ApiKey}&steamids={ids}"; try { webrequest.Enqueue(url, null, (code, body) => { try { if (code == 200 && !string.IsNullOrEmpty(body)) { var json = JObject.Parse(body); var players = json?["response"]?["players"] as JArray; if (players != null) { foreach (var pl in players) { if (!ulong.TryParse(pl?["steamid"]?.ToString(), out var sid)) continue; result[sid] = new PlayerSummary { PersonaName = pl?["personaname"]?.ToString() ?? "?", LastLogoff = pl?["lastlogoff"]?.Value() ?? 0 }; } } } } catch (Exception ex) { PrintError($"[API] GetPlayerSummaries parse error: {ex.Message}"); } FetchSummaryBatch(batches, index + 1, result, callback); }, this, RequestMethod.GET, null, _config.SteamApi.RequestTimeoutSeconds); } catch (Exception ex) { PrintError($"[API] GetPlayerSummaries request error: {ex.Message}"); FetchSummaryBatch(batches, index + 1, result, callback); } } private void RenderStatusTable(Action reply, Dictionary entries, Dictionary summaries, string userId) { var onlineIds = new HashSet(); foreach (var bp in BasePlayer.activePlayerList) { if (bp != null) onlineIds.Add(bp.userID); } var sb = new StringBuilder(); sb.AppendLine(L("StatusHeader", userId, entries.Count)); var tzLabel = _displayOffset == TimeSpan.Zero ? "UTC" : $"UTC{(_displayOffset.TotalHours >= 0 ? "+" : "")}{_displayOffset.TotalHours:0.##}"; sb.AppendLine($" {"Reason",-8} {"Online",-7} {"SteamID",-20} {"Name",-24} {"Last-Login (" + tzLabel + ")"}"); sb.AppendLine($" {"------",-8} {"------",-7} {"-------",-20} {"----",-24} {"----------------"}"); var sorted = entries .OrderBy(e => e.Value == "allowed" ? 0 : e.Value == "direct" ? 1 : 2) .ThenBy(e => e.Key); foreach (var kvp in sorted) { var id = kvp.Key; var reason = kvp.Value; var online = onlineIds.Contains(id) ? "yes" : "no"; var name = "?"; var lastLogin = "-"; if (summaries.TryGetValue(id, out var s)) { name = s.PersonaName ?? "?"; if (s.LastLogoff > 0) { lastLogin = DateTimeOffset.FromUnixTimeSeconds(s.LastLogoff) .ToOffset(_displayOffset) .ToString("yyyy-MM-ddTHH:mm:sszzz"); } } if (name.Length > 24) name = name.Substring(0, 21) + "..."; sb.AppendLine($" {reason,-8} {online,-7} {id,-20} {name,-24} {lastLogin}"); } sb.AppendLine(); var refreshVal = _refreshInProgress ? L("StatusRefreshInProgress", userId) : L("StatusRefreshIdle", userId); var fcVal = _config.AccessControl.EnableFriendCheck ? L("StatusFcOn", userId) : L("StatusFcOff", userId); var apiVal = !string.IsNullOrWhiteSpace(_config.SteamApi.ApiKey) ? L("StatusApiOk", userId) : L("StatusApiMissing", userId); sb.AppendLine(L("StatusFooter", userId, refreshVal, fcVal, apiVal)); reply(sb.ToString()); } private void CmdRefresh(Action reply, string[] args, string userId) { if (args.Length > 1) { if (!ulong.TryParse(args[1], out var id)) { reply(L("InvalidSteamId", userId, args[1])); return; } reply(L("RefreshFetching", userId, id)); FetchFriendList(id, () => { RebuildEffectiveFriendSet(); SyncCacheToData(); SaveDataFile(); reply(L("RefreshComplete", userId, id)); }); return; } if (_refreshInProgress) { reply(L("RefreshAlreadyRunning", userId)); return; } reply(L("RefreshStarting", userId)); RefreshAllFriends(); } private void CmdAdd(Action reply, string[] args, string userId) { if (args.Length < 2) { reply(L("AddUsage", userId)); return; } if (!ulong.TryParse(args[1], out var id) || id < 76561190000000000UL) { reply(L("InvalidSteamId", userId, args[1])); return; } if (_additionalAllowedSteamIds.Contains(id)) { reply(L("AddAlreadyExists", userId, id)); return; } _additionalAllowedSteamIds.Add(id); if (!_config.AccessControl.AdditionalAllowedSteamIds.Contains(id.ToString())) _config.AccessControl.AdditionalAllowedSteamIds.Add(id.ToString()); SaveConfig(); RebuildEffectiveFriendSet(); reply(L("AddAdded", userId, id)); if (_config.AccessControl.EnableFriendCheck && !string.IsNullOrWhiteSpace(_config.SteamApi.ApiKey)) { reply(L("AddFetchingFriends", userId, id)); FetchFriendList(id, () => { RebuildEffectiveFriendSet(); SyncCacheToData(); SaveDataFile(); reply(L("AddFetchComplete", userId, id)); }); } } private void CmdRemove(Action reply, string[] args, string userId) { if (args.Length < 2) { reply(L("RemoveUsage", userId)); return; } if (!ulong.TryParse(args[1], out var id)) { reply(L("InvalidSteamId", userId, args[1])); return; } if (_allowedSteamIds.Contains(id)) { reply(L("RemoveInAllowed", userId, id)); return; } bool removed = _additionalAllowedSteamIds.Remove(id); _config.AccessControl.AdditionalAllowedSteamIds.RemoveAll(s => s?.Trim() == id.ToString()); SaveConfig(); _friendsCache.Remove(id); RebuildEffectiveFriendSet(); SyncCacheToData(); SaveDataFile(); reply(removed ? L("RemoveRemoved", userId, id) : L("RemoveNotFound", userId, id)); } private void CmdCheck(Action reply, string[] args, string userId) { if (args.Length < 2) { reply(L("CheckUsage", userId)); return; } if (!ulong.TryParse(args[1], out var id)) { reply(L("InvalidSteamId", userId, args[1])); return; } var sb = new StringBuilder(); sb.AppendLine(L("CheckHeader", userId, id)); if (IsBypassed(id.ToString(), id)) sb.AppendLine(L("CheckBypass", userId)); else if (IsDirectAllowed(id)) sb.AppendLine(L("CheckDirect", userId)); else if (_config.AccessControl.EnableFriendCheck && IsFriendAllowed(id)) { sb.AppendLine(L("CheckFriend", userId)); foreach (var kvp in _friendsCache) { if (kvp.Value?.FriendSteamIds != null && kvp.Value.FriendSteamIds.Contains(id)) sb.AppendLine(L("CheckFriendOf", userId, kvp.Key)); } } else sb.AppendLine(L("CheckDenied", userId)); sb.AppendLine($" AllowedSteamIds: {_allowedSteamIds.Contains(id)}"); sb.AppendLine($" AdditionalAllowedSteamIds: {_additionalAllowedSteamIds.Contains(id)}"); sb.AppendLine($" EffectiveFriendSet: {_effectiveFriendSet.Contains(id)}"); reply(sb.ToString()); } private void CmdDump(Action reply, string userId) { var owners = GetAllOwnerIds(); var sb = new StringBuilder(); sb.AppendLine(L("DumpHeader", userId, owners.Count)); foreach (var ownerId in owners.OrderBy(x => x)) { if (_friendsCache.TryGetValue(ownerId, out var e)) { var lastOk = e.LastSuccessUnix > 0 ? DateTimeOffset.FromUnixTimeSeconds(e.LastSuccessUnix).ToString("yyyy-MM-dd HH:mm:ss UTC") : "Never"; var lastTry = e.LastAttemptUnix > 0 ? DateTimeOffset.FromUnixTimeSeconds(e.LastAttemptUnix).ToString("yyyy-MM-dd HH:mm:ss UTC") : "Never"; sb.AppendLine($" {ownerId}:"); sb.AppendLine($" Friends: {e.FriendSteamIds?.Count ?? 0}"); sb.AppendLine($" Private: {e.IsPrivateOrUnauthorized}"); sb.AppendLine($" LastSuccess: {lastOk}"); sb.AppendLine($" LastAttempt: {lastTry}"); } else { sb.AppendLine(L("DumpNoData", userId, ownerId)); } } reply(sb.ToString()); } #endregion #region Lang protected override void LoadDefaultMessages() { lang.RegisterMessages(new Dictionary { ["DenyDefault"] = "This server is whitelisted. You are not authorized to join.", ["NoPermission"] = "You do not have permission to use this command.", ["InvalidSteamId"] = "Invalid SteamID: {0}", ["HelpHeader"] = "=== RUNWhitelist ===", ["HelpStatus"] = "Show allowed accounts list", ["HelpRefresh"] = "Refresh all owner friend lists", ["HelpRefreshId"] = "Refresh a specific owner's friend list", ["HelpAdd"] = "Add to additional whitelist", ["HelpRemove"] = "Remove from additional whitelist", ["HelpCheck"] = "Check permission status for a specific ID", ["HelpDump"] = "Show per-owner cache information", ["StatusNoAccounts"] = "=== RUNWhitelist Status ===\n No allowed accounts", ["StatusFetching"] = "Fetching player info from Steam API ({0} accounts)...", ["StatusHeader"] = "=== RUNWhitelist Status ({0} accounts) ===", ["StatusFooter"] = " Refresh: {0} | Friend check: {1} | API key: {2}", ["StatusRefreshIdle"] = "idle", ["StatusRefreshInProgress"] = "in progress", ["StatusFcOn"] = "ON", ["StatusFcOff"] = "OFF", ["StatusApiOk"] = "configured", ["StatusApiMissing"] = "MISSING", ["RefreshFetching"] = "Fetching friend list for {0}...", ["RefreshComplete"] = "Friend list refresh complete for {0}.", ["RefreshAlreadyRunning"] = "Refresh is already in progress.", ["RefreshStarting"] = "Starting friend list refresh for all owners...", ["AddUsage"] = "Usage: wl add ", ["AddAlreadyExists"] = "{0} is already in additional whitelist.", ["AddAdded"] = "{0} has been added to additional whitelist.", ["AddFetchingFriends"] = "Fetching friend list for {0}...", ["AddFetchComplete"] = "Friend list retrieved for {0}. Effective set updated.", ["RemoveUsage"] = "Usage: wl remove ", ["RemoveInAllowed"] = "{0} is in AllowedSteamIds (please edit config directly).", ["RemoveRemoved"] = "{0} has been removed from additional whitelist. Cache updated.", ["RemoveNotFound"] = "{0} is not in additional whitelist.", ["CheckUsage"] = "Usage: wl check ", ["CheckHeader"] = "=== Check: {0} ===", ["CheckBypass"] = " Result: ALLOWED (bypass / server owner)", ["CheckDirect"] = " Result: ALLOWED (direct whitelist)", ["CheckFriend"] = " Result: ALLOWED (friend of whitelisted owner)", ["CheckFriendOf"] = " -> Friend of {0}", ["CheckDenied"] = " Result: DENIED", ["DumpHeader"] = "=== RUNWhitelist Cache Dump ({0} owners) ===", ["DumpNoData"] = " {0}: (no cache data)" }, this); lang.RegisterMessages(new Dictionary { ["DenyDefault"] = "このサーバーはホワイトリスト制です。接続が許可されていません。", ["NoPermission"] = "このコマンドの実行権限がありません。", ["InvalidSteamId"] = "無効な SteamID: {0}", ["HelpHeader"] = "=== RUNWhitelist ===", ["HelpStatus"] = "許可アカウント一覧", ["HelpRefresh"] = "全オーナーのフレンドリスト更新", ["HelpRefreshId"] = "特定オーナーのフレンドリスト更新", ["HelpAdd"] = "追加ホワイトリストに追加", ["HelpRemove"] = "追加ホワイトリストから削除", ["HelpCheck"] = "指定IDの許可状態を確認", ["HelpDump"] = "オーナー別キャッシュ情報表示", ["StatusNoAccounts"] = "=== RUNWhitelist Status ===\n 許可アカウントなし", ["StatusFetching"] = "Steam API からプレイヤー情報を取得中 ({0} accounts)...", ["StatusHeader"] = "=== RUNWhitelist Status ({0} accounts) ===", ["StatusFooter"] = " Refresh: {0} | Friend check: {1} | API key: {2}", ["StatusRefreshIdle"] = "idle", ["StatusRefreshInProgress"] = "更新中", ["StatusFcOn"] = "ON", ["StatusFcOff"] = "OFF", ["StatusApiOk"] = "設定済み", ["StatusApiMissing"] = "未設定", ["RefreshFetching"] = "{0} のフレンドリストを取得中...", ["RefreshComplete"] = "{0} のフレンドリスト更新完了。", ["RefreshAlreadyRunning"] = "リフレッシュは既に実行中です。", ["RefreshStarting"] = "全オーナーのフレンドリスト更新を開始します...", ["AddUsage"] = "Usage: wl add ", ["AddAlreadyExists"] = "{0} は既に追加ホワイトリストに含まれています。", ["AddAdded"] = "{0} を追加ホワイトリストに追加しました。", ["AddFetchingFriends"] = "{0} のフレンドリストを取得中...", ["AddFetchComplete"] = "{0} のフレンドリスト取得完了。許可セット更新済み。", ["RemoveUsage"] = "Usage: wl remove ", ["RemoveInAllowed"] = "{0} は AllowedSteamIds に含まれています(config を直接編集してください)。", ["RemoveRemoved"] = "{0} を追加ホワイトリストから削除しました。キャッシュ更新済み。", ["RemoveNotFound"] = "{0} は追加ホワイトリストに含まれていません。", ["CheckUsage"] = "Usage: wl check ", ["CheckHeader"] = "=== 確認: {0} ===", ["CheckBypass"] = " 判定: 許可 (bypass / サーバーオーナー)", ["CheckDirect"] = " 判定: 許可 (直接ホワイトリスト)", ["CheckFriend"] = " 判定: 許可 (ホワイトリストオーナーのフレンド)", ["CheckFriendOf"] = " → {0} のフレンド", ["CheckDenied"] = " 判定: 拒否", ["DumpHeader"] = "=== RUNWhitelist キャッシュダンプ ({0} owners) ===", ["DumpNoData"] = " {0}: (キャッシュデータなし)" }, this, "ja"); } #endregion #region Helpers private string L(string key, string userId = null, params object[] args) { var msg = lang.GetMessage(key, this, userId); return args.Length > 0 ? string.Format(msg, args) : msg; } private void LogIf(bool condition, string message) { if (condition) Puts(message); } #endregion } }