Please disclose if any significant portion of your mod was created using AI tools by adding the 'AI Generated' category. Failing to do so may result in the mod being removed from Thunderstore.
Decompiled source of ServerInfo v2.2.7
ServerInfo.dll
Decompiled 6 days agousing System; using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; using BepInEx; using BepInEx.Bootstrap; using BepInEx.Configuration; using BepInEx.Logging; using Microsoft.CodeAnalysis; using Newtonsoft.Json; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)] [assembly: InternalsVisibleTo("ServerInfo.Tests")] [assembly: AssemblyTitle("Server Info")] [assembly: AssemblyDescription("")] [assembly: AssemblyConfiguration("")] [assembly: AssemblyCompany("")] [assembly: AssemblyProduct("Server Info")] [assembly: AssemblyCopyright("Copyright © 2026 Odin_Sons")] [assembly: AssemblyTrademark("")] [assembly: ComVisible(false)] [assembly: Guid("818C1BFD-8345-47F8-9C45-F52A40B71D78")] [assembly: AssemblyFileVersion("2.2.7.0")] [assembly: TargetFramework(".NETFramework,Version=v4.7.2", FrameworkDisplayName = ".NET Framework 4.7.2")] [assembly: AssemblyVersion("2.2.7.0")] [module: RefSafetyRules(11)] namespace Microsoft.CodeAnalysis { [CompilerGenerated] [Embedded] internal sealed class EmbeddedAttribute : Attribute { } } namespace System.Runtime.CompilerServices { [CompilerGenerated] [Embedded] [AttributeUsage(AttributeTargets.Module, AllowMultiple = false, Inherited = false)] internal sealed class RefSafetyRulesAttribute : Attribute { public readonly int Version; public RefSafetyRulesAttribute(int P_0) { Version = P_0; } } } namespace ServerInfo { internal static class AssemblyMetadataReader { public static string GetDescription(Assembly assembly) { string text = assembly?.GetCustomAttribute<AssemblyDescriptionAttribute>()?.Description; if (!string.IsNullOrWhiteSpace(text)) { return text; } return null; } public static string GetRepositoryUrl(Assembly assembly) { string text = assembly?.GetCustomAttributes<AssemblyMetadataAttribute>().FirstOrDefault((AssemblyMetadataAttribute a) => a.Key == "RepositoryUrl")?.Value; if (!string.IsNullOrWhiteSpace(text)) { return text; } return null; } } internal static class EndpointPath { public static string Normalize(string path) { path = (string.IsNullOrWhiteSpace(path) ? "/serverinfo" : path.Trim().ToLowerInvariant()); if (!path.StartsWith("/")) { return "/" + path; } return path; } } internal static class GameReflection { private const BindingFlags AnyInstanceMember = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; private const BindingFlags AnyStaticMember = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; public static Type FindType(string name) { Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies(); foreach (Assembly assembly in assemblies) { Type type; try { type = assembly.GetType(name, throwOnError: false); } catch { continue; } if (type != null) { return type; } } return null; } public static object GetStaticMember(Type type, string name) { PropertyInfo property = type.GetProperty(name, BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); if (property != null) { return property.GetValue(null); } return type.GetField(name, BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)?.GetValue(null); } public static object GetMember(object target, string name) { if (target == null) { return null; } Type type = target.GetType(); FieldInfo field = type.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (field != null) { return field.GetValue(target); } return type.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)?.GetValue(target); } public static object Invoke(object target, string methodName, params object[] args) { return target?.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)?.Invoke(target, args); } } internal class GameServerInfoProvider { private readonly SteamAvatarService steamAvatars; private readonly PackageManifestReader manifestReader; private readonly Func<int> cacheIntervalSeconds; private readonly Func<ModMetadataSource[]> metadataSources; private ServerInfoSnapshot cache; private DateTime lastUpdate = DateTime.MinValue; public GameServerInfoProvider(SteamAvatarService steamAvatars, PackageManifestReader manifestReader, Func<int> cacheIntervalSeconds, Func<ModMetadataSource[]> metadataSources) { this.steamAvatars = steamAvatars; this.manifestReader = manifestReader; this.cacheIntervalSeconds = cacheIntervalSeconds; this.metadataSources = metadataSources; } public ServerInfoSnapshot GetSnapshot() { if (cache != null && (DateTime.Now - lastUpdate).TotalSeconds <= (double)cacheIntervalSeconds()) { return cache; } Type type = GameReflection.FindType("ZNet"); object obj = ((type != null) ? GameReflection.GetStaticMember(type, "instance") : null); if (obj == null) { return null; } List<PlayerInfo> list = BuildPlayerInfos(obj); cache = new ServerInfoSnapshot { Name = (GameReflection.Invoke(obj, "GetWorldName") as string), PlayersCount = list.Count, Players = list.ToArray(), Mods = Chainloader.PluginInfos.Values.Select(BuildModDetails).ToArray() }; lastUpdate = DateTime.Now; return cache; } private List<PlayerInfo> BuildPlayerInfos(object zs) { List<PlayerInfo> list = new List<PlayerInfo>(); if (GameReflection.Invoke(zs, "GetConnectedPeers") is IEnumerable enumerable) { foreach (object item in enumerable) { if (item != null && GameReflection.Invoke(item, "IsReady") as bool? == true) { string name = GameReflection.GetMember(item, "m_playerName") as string; string steamID = SteamAvatarService.ExtractSteamID(GameReflection.Invoke(GameReflection.GetMember(item, "m_socket"), "GetHostName") as string); list.Add(new PlayerInfo { Name = name, SteamID = steamID }); } } } IEnumerable<string> steamIds = from p in list where SteamAvatarService.IsValidSteamID(p.SteamID) select p.SteamID; Dictionary<string, string> avatarUrls = steamAvatars.GetAvatarUrls(steamIds); foreach (PlayerInfo item2 in list) { if (avatarUrls.TryGetValue(item2.SteamID, out var value)) { item2.AvatarUrl = value; } } return list; } private ModDetails BuildModDetails(PluginInfo pi) { PackageManifest packageManifest = manifestReader.Find(pi); Assembly assembly = ((object)pi.Instance)?.GetType().Assembly; string text = null; string text2 = null; string[] array = null; ModMetadataSource[] array2 = metadataSources(); for (int i = 0; i < array2.Length; i++) { switch (array2[i]) { case ModMetadataSource.Manifest: text = Coalesce(text, packageManifest?.Description); text2 = Coalesce(text2, packageManifest?.WebsiteUrl); array = Coalesce(array, packageManifest?.Dependencies); break; case ModMetadataSource.Assembly: text = Coalesce(text, AssemblyMetadataReader.GetDescription(assembly)); text2 = Coalesce(text2, AssemblyMetadataReader.GetRepositoryUrl(assembly)); array = Coalesce(array, pi.Dependencies?.Select((BepInDependency d) => d.DependencyGUID).ToArray()); break; } } return new ModDetails { name = pi.Metadata.Name, guid = pi.Metadata.GUID, version = pi.Metadata.Version?.ToString(), description = text, websiteUrl = text2, dependencies = array, @namespace = packageManifest?.Namespace, packageName = packageManifest?.Name }; } private static string Coalesce(string current, string candidate) { if (!string.IsNullOrWhiteSpace(current)) { return current; } return candidate; } private static string[] Coalesce(string[] current, string[] candidate) { if (current != null && current.Length != 0) { return current; } return candidate; } } internal class PlayerInfo { public string Name { get; set; } public string SteamID { get; set; } public string AvatarUrl { get; set; } } internal class ModDetails { public string name { get; set; } public string guid { get; set; } public string version { get; set; } public string description { get; set; } public string websiteUrl { get; set; } public string[] dependencies { get; set; } public string @namespace { get; set; } public string packageName { get; set; } } internal class ServerInfoSnapshot { public string Name { get; set; } public int PlayersCount { get; set; } public PlayerInfo[] Players { get; set; } public ModDetails[] Mods { get; set; } } internal enum ModMetadataSource { Manifest, Assembly } internal static class ModMetadataSources { public const string Default = "Manifest,Assembly"; private static readonly ModMetadataSource[] DefaultOrder = new ModMetadataSource[2] { ModMetadataSource.Manifest, ModMetadataSource.Assembly }; public static ModMetadataSource[] Parse(string value, PluginLog log) { ModMetadataSource result; ModMetadataSource[] array = (from token in (value ?? "").Split(new char[1] { ',' }) select token.Trim() into token where token.Length > 0 select (!Enum.TryParse<ModMetadataSource>(token, ignoreCase: true, out result)) ? ((ModMetadataSource?)null) : new ModMetadataSource?(result) into source where source.HasValue select source.Value).Distinct().ToArray(); if (array.Length != 0) { return array; } log.Warning("MetadataSources config value \"" + value + "\" has no recognized sources (expected Manifest and/or Assembly, comma-separated) — falling back to \"Manifest,Assembly\"."); return DefaultOrder; } } internal class PackageManifest { [JsonProperty("name")] public string Name { get; set; } [JsonProperty("version_number")] public string VersionNumber { get; set; } [JsonProperty("website_url")] public string WebsiteUrl { get; set; } [JsonProperty("description")] public string Description { get; set; } [JsonProperty("dependencies")] public string[] Dependencies { get; set; } public string Namespace { get; set; } } internal class PackageManifestReader { private readonly PluginLog log; private readonly ConcurrentDictionary<string, PackageManifest> cache = new ConcurrentDictionary<string, PackageManifest>(); private static readonly Regex PackageFolderWithVersion = new Regex("^(?<namespace>[^-]+)-(?<name>.+)-(?<version>\\d[\\d.]*(?:-[\\w.]+)?)$"); private static readonly Regex PackageFolderNoVersion = new Regex("^(?<namespace>[^-]+)-(?<name>.+)$"); public PackageManifestReader(PluginLog log) { this.log = log; } internal static string ParseNamespace(string folderName) { folderName = folderName ?? ""; Match match = PackageFolderWithVersion.Match(folderName); if (!match.Success) { match = PackageFolderNoVersion.Match(folderName); } if (!match.Success) { return null; } return match.Groups["namespace"].Value; } public PackageManifest Find(PluginInfo pi) { return cache.GetOrAdd(pi.Metadata.GUID, delegate { string location = pi.Location; if (string.IsNullOrEmpty(location)) { return (PackageManifest)null; } string directoryName = Path.GetDirectoryName(location); string text = ((directoryName != null) ? Path.GetDirectoryName(directoryName) : null); string[] array = new string[2] { directoryName, text }; foreach (string text2 in array) { if (text2 != null) { string path = Path.Combine(text2, "manifest.json"); if (File.Exists(path)) { try { PackageManifest packageManifest = JsonConvert.DeserializeObject<PackageManifest>(File.ReadAllText(path)); if (packageManifest != null) { packageManifest.Namespace = ParseNamespace(Path.GetFileName(text2)); } return packageManifest; } catch (Exception ex) { log.Warning("Unable to parse manifest.json for " + pi.Metadata.Name + ": " + ex.Message); return (PackageManifest)null; } } } } return (PackageManifest)null; }); } } internal class PluginLog { private readonly ManualLogSource source; private readonly Func<LogLevel> enabledLevels; public PluginLog(ManualLogSource source, Func<LogLevel> enabledLevels) { this.source = source; this.enabledLevels = enabledLevels; } public void Error(string message) { //IL_0006: Unknown result type (might be due to invalid IL or missing references) if (((Enum)enabledLevels()).HasFlag((Enum)(object)(LogLevel)2)) { source.LogError((object)message); } } public void Warning(string message) { //IL_0006: Unknown result type (might be due to invalid IL or missing references) if (((Enum)enabledLevels()).HasFlag((Enum)(object)(LogLevel)4)) { source.LogWarning((object)message); } } public void Info(string message) { //IL_0006: Unknown result type (might be due to invalid IL or missing references) if (((Enum)enabledLevels()).HasFlag((Enum)(object)(LogLevel)16)) { source.LogInfo((object)message); } } } [BepInPlugin("Odin_Sons.ServerInfo", "Server Info", "2.2.7")] public class ServerInfoPlugin : BaseUnityPlugin { private static PluginLog Log; private HttpListener listener; private bool running; private ConfigEntry<LogLevel> logLevel; private ConfigEntry<int> port; private ConfigEntry<string> serverInfoPath; private ConfigEntry<string> domain; private ConfigEntry<string> allowedOrigin; private ConfigEntry<int> requestTimeoutSeconds; private ConfigEntry<int> cacheIntervalSeconds; private ConfigEntry<int> avatarCacheMinutes; private ConfigEntry<string> steamApiKey; private ConfigEntry<string> modMetadataSources; private readonly ConcurrentQueue<Action> mainThreadQueue = new ConcurrentQueue<Action>(); private GameServerInfoProvider serverInfoProvider; private void Awake() { //IL_0068: Unknown result type (might be due to invalid IL or missing references) //IL_0072: Expected O, but got Unknown //IL_010a: Unknown result type (might be due to invalid IL or missing references) //IL_0114: Expected O, but got Unknown //IL_0140: Unknown result type (might be due to invalid IL or missing references) //IL_014a: Expected O, but got Unknown //IL_0177: Unknown result type (might be due to invalid IL or missing references) //IL_0181: Expected O, but got Unknown logLevel = ((BaseUnityPlugin)this).Config.Bind<LogLevel>("Logging", "LogLevel", (LogLevel)6, "Which message levels this plugin writes to the BepInEx log. Flags — combine values in the .cfg (e.g. \"Error, Warning, Info\"). Info adds startup/diagnostic messages not needed for normal operation."); Log = new PluginLog(((BaseUnityPlugin)this).Logger, () => logLevel.Value); port = ((BaseUnityPlugin)this).Config.Bind<int>("Server", "Port", 8880, new ConfigDescription("HTTP port for the web server.", (AcceptableValueBase)(object)new AcceptableValueRange<int>(1, 65535), Array.Empty<object>())); serverInfoPath = ((BaseUnityPlugin)this).Config.Bind<string>("Server", "ServerInfoPath", "/serverinfo", "URL path the server info endpoint is served on."); domain = ((BaseUnityPlugin)this).Config.Bind<string>("Server", "Domain", "", "Public domain or IP this server is reachable at. Optional and purely informational to the plugin itself — it only gates AllowedOrigin below: leave it empty while the server isn't publicly reachable yet, and the plugin won't advertise a CORS policy for it."); allowedOrigin = ((BaseUnityPlugin)this).Config.Bind<string>("Server", "AllowedOrigin", "*", "Value sent as Access-Control-Allow-Origin — so a status page on another domain can read the response directly from the browser — but only once Domain (above) is set. Must exactly match the calling page's scheme+host+port (e.g. \"https://status.example.com\"); \"*\" allows any site."); requestTimeoutSeconds = ((BaseUnityPlugin)this).Config.Bind<int>("Server", "RequestTimeoutSeconds", 5, new ConfigDescription("How long, in seconds, an HTTP request waits for the game's main thread before returning a timeout error.", (AcceptableValueBase)(object)new AcceptableValueRange<int>(1, 60), Array.Empty<object>())); cacheIntervalSeconds = ((BaseUnityPlugin)this).Config.Bind<int>("Cache", "CacheIntervalSeconds", 5, new ConfigDescription("How often, in seconds, the server/player list is recomputed. Lower is fresher but does more work per request.", (AcceptableValueBase)(object)new AcceptableValueRange<int>(1, 3600), Array.Empty<object>())); avatarCacheMinutes = ((BaseUnityPlugin)this).Config.Bind<int>("Cache", "AvatarCacheMinutes", 60, new ConfigDescription("How long, in minutes, a fetched Steam avatar URL is reused before being re-fetched, and how long an inactive player's entry is kept before eviction.", (AcceptableValueBase)(object)new AcceptableValueRange<int>(1, 1440), Array.Empty<object>())); steamApiKey = ((BaseUnityPlugin)this).Config.Bind<string>("Steam", "SteamApiKey", "", "Steam Web API key used to fetch player avatars."); modMetadataSources = ((BaseUnityPlugin)this).Config.Bind<string>("Mods", "MetadataSources", "Manifest,Assembly", "Comma-separated order of precedence for a mod's description/websiteUrl/dependencies. Recognized values: \"Manifest\" (its manifest.json) and \"Assembly\" (its own compiled metadata). For each field, the first source in the list with a non-empty value wins. Example: \"Assembly,Manifest\" prefers assembly metadata; \"Manifest\" alone disables the assembly fallback; \"Assembly\" alone ignores manifest.json entirely. Does not affect namespace/packageName, which only ever come from a manifest.json."); SteamAvatarService steamAvatars = new SteamAvatarService(Log, () => steamApiKey.Value, () => avatarCacheMinutes.Value); PackageManifestReader manifestReader = new PackageManifestReader(Log); serverInfoProvider = new GameServerInfoProvider(steamAvatars, manifestReader, () => cacheIntervalSeconds.Value, () => ModMetadataSources.Parse(modMetadataSources.Value, Log)); StartServer(); } private void Update() { Action result; while (mainThreadQueue.TryDequeue(out result)) { result?.Invoke(); } } private void OnDestroy() { StopServer(); } private void StartServer() { try { listener = new HttpListener(); listener.Prefixes.Add($"http://+:{port.Value}/"); listener.Start(); running = true; Task.Run((Func<Task?>)HandleLoop); Log.Info($"Web server listening on port {port.Value}, serving {EndpointPath.Normalize(serverInfoPath.Value)}"); } catch (HttpListenerException ex) when (ex.ErrorCode == 5) { Log.Error($"Access denied binding port {port.Value}. On Windows, run once as administrator " + "(replace YOUR_USERNAME with the account running this server — check with 'whoami'): " + $"netsh http add urlacl url=http://+:{port.Value}/ user=YOUR_USERNAME " + "— see README.md for details and a less strict fallback."); } catch (Exception arg) { Log.Error($"Failed to start server: {arg}"); } } private void StopServer() { running = false; if (listener != null) { listener.Stop(); listener.Close(); listener = null; } } private async Task HandleLoop() { while (running) { try { await HandleRequest(await listener.GetContextAsync()); } catch (Exception arg) { if (!running) { break; } Log.Error($"Error in handle loop: {arg}"); } } } private async Task HandleRequest(HttpListenerContext ctx) { if (!string.IsNullOrWhiteSpace(domain.Value)) { ctx.Response.Headers.Add("Access-Control-Allow-Origin", allowedOrigin.Value); } string path = ctx.Request.Url.AbsolutePath.ToLowerInvariant(); string text = EndpointPath.Normalize(serverInfoPath.Value); string s; try { if (path == "/") { s = Json(new { endpoints = new string[1] { text } }); } else if (path == text) { s = await GetServerInfoJsonAsync(); } else { ctx.Response.StatusCode = 404; s = Json(new { error = "Not found" }); } } catch (Exception ex) { Log.Error($"Unhandled error serving {path}: {ex}"); ctx.Response.StatusCode = 500; s = Json(new { error = "Internal server error", details = ex.Message }); } byte[] bytes = Encoding.UTF8.GetBytes(s); ctx.Response.ContentType = "application/json"; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } private async Task<string> GetServerInfoJsonAsync() { TaskCompletionSource<ServerInfoSnapshot> tcs = new TaskCompletionSource<ServerInfoSnapshot>(); Task timeout = Task.Delay(TimeSpan.FromSeconds(requestTimeoutSeconds.Value)); mainThreadQueue.Enqueue(delegate { try { tcs.SetResult(serverInfoProvider.GetSnapshot()); } catch (Exception exception) { tcs.SetException(exception); } }); if (await Task.WhenAny(new Task[2] { tcs.Task, timeout }) == timeout) { return Json(new { error = "Request timed out" }); } ServerInfoSnapshot serverInfoSnapshot = await tcs.Task; if (serverInfoSnapshot == null) { return Json(new { error = "Server not initialized" }); } return Json(new { name = serverInfoSnapshot.Name, playersCount = serverInfoSnapshot.PlayersCount, players = serverInfoSnapshot.Players, mods = serverInfoSnapshot.Mods }); } private string Json(object obj) { return JsonConvert.SerializeObject(obj, (Formatting)1); } } internal class SteamAvatarService { private class SteamApiResponse { public SteamResponse Response { get; set; } } private class SteamResponse { public List<SteamPlayer> Players { get; set; } } private class SteamPlayer { [JsonProperty("steamid")] public string SteamId { get; set; } [JsonProperty("avatarfull")] public string AvatarFull { get; set; } } private const int MaxIdsPerRequest = 100; private static readonly HttpClient httpClient = new HttpClient(); private readonly PluginLog log; private readonly Func<string> apiKey; private readonly Func<int> cacheMinutes; private readonly ConcurrentDictionary<string, (string Url, DateTime CachedAt)> cache = new ConcurrentDictionary<string, (string, DateTime)>(); private DateTime lastPrune = DateTime.MinValue; private bool warnedMissingApiKey; public SteamAvatarService(PluginLog log, Func<string> apiKey, Func<int> cacheMinutes) { this.log = log; this.apiKey = apiKey; this.cacheMinutes = cacheMinutes; } public static string ExtractSteamID(string hostName) { if (string.IsNullOrEmpty(hostName)) { return ""; } if (IsValidSteamID(hostName)) { return hostName; } if (hostName.StartsWith("Steam_")) { return hostName.Substring(6); } return ""; } public static bool IsValidSteamID(string id) { if (string.IsNullOrEmpty(id) || !long.TryParse(id, out var _)) { return false; } if (id.Length == 17) { return id.StartsWith("7656"); } return false; } public Dictionary<string, string> GetAvatarUrls(IEnumerable<string> steamIds) { Prune(); string[] array = steamIds.Distinct().ToArray(); Dictionary<string, string> dictionary = new Dictionary<string, string>(); int num = cacheMinutes(); List<string> list = new List<string>(); string[] array2 = array; foreach (string text in array2) { if (cache.TryGetValue(text, out (string, DateTime) value) && (DateTime.Now - value.Item2).TotalMinutes < (double)num) { (dictionary[text], _) = value; } else { list.Add(text); } } if (list.Count == 0) { return dictionary; } if (string.IsNullOrEmpty(apiKey())) { if (!warnedMissingApiKey) { log.Warning("SteamApiKey is not set in config — player avatars will be omitted."); warnedMissingApiKey = true; } return dictionary; } warnedMissingApiKey = false; for (int j = 0; j < list.Count; j += 100) { string[] steamIds2 = list.Skip(j).Take(100).ToArray(); foreach (KeyValuePair<string, string> item in FetchBatch(steamIds2)) { dictionary[item.Key] = item.Value; cache[item.Key] = (item.Value, DateTime.Now); } } return dictionary; } private Dictionary<string, string> FetchBatch(string[] steamIds) { Dictionary<string, string> dictionary = new Dictionary<string, string>(); try { string text = string.Join(",", steamIds); string text2 = "https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=" + apiKey() + "&steamids=" + text; foreach (SteamPlayer item in JsonConvert.DeserializeObject<SteamApiResponse>(httpClient.GetStringAsync(text2).GetAwaiter().GetResult())?.Response?.Players ?? new List<SteamPlayer>()) { if (!string.IsNullOrEmpty(item.SteamId)) { dictionary[item.SteamId] = item.AvatarFull ?? ""; } } } catch (Exception ex) { log.Warning("Unable to load Steam avatars: " + ex.Message); } return dictionary; } private void Prune() { int num = cacheMinutes(); if ((DateTime.Now - lastPrune).TotalMinutes < (double)num) { return; } lastPrune = DateTime.Now; DateTime dateTime = DateTime.Now.AddMinutes(-num); foreach (KeyValuePair<string, (string, DateTime)> item in cache) { if (item.Value.Item2 < dateTime) { cache.TryRemove(item.Key, out (string, DateTime) _); } } } } }