Decompiled source of ServerInfo v2.2.7

ServerInfo.dll

Decompiled 6 days ago
using 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) _);
				}
			}
		}
	}
}