Decompiled source of Clanheim Server Watch v1.0.1

BepInEx/plugins/ClanheimServerWatch/ClanheimServerWatch.dll

Decompiled a day ago
using System;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using BepInEx;
using BepInEx.Bootstrap;
using BepInEx.Logging;
using Microsoft.CodeAnalysis;
using UnityEngine;

[assembly: CompilationRelaxations(8)]
[assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)]
[assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)]
[assembly: AssemblyTitle("Clanheim Server Watch")]
[assembly: AssemblyDescription("The Watchtower — private server telemetry for Clanheim")]
[assembly: AssemblyCompany("Clanheim")]
[assembly: AssemblyProduct("Clanheim Server Watch")]
[assembly: AssemblyFileVersion("1.0.1.0")]
[assembly: AssemblyInformationalVersion("1.0.1")]
[assembly: AssemblyVersion("1.0.1.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 Clanheim.ServerWatch
{
	internal static class Text
	{
		internal static string Limit(string s, int n)
		{
			s = s ?? "";
			if (s.Length > n)
			{
				return s.Substring(0, n - 1) + "…";
			}
			return s;
		}

		internal static string Safe(string s, int n = 100)
		{
			s = Regex.Replace(s ?? "", "[\\p{Cc}\\p{Cf}]", " ");
			string[] array = new string[11]
			{
				"@", "<", ">", "`", "*", "_", "~", "|", "[", "]",
				"\\"
			};
			foreach (string oldValue in array)
			{
				s = s.Replace(oldValue, "");
			}
			return Limit(s.Trim(), n);
		}

		internal static string Json(string s)
		{
			StringBuilder stringBuilder = new StringBuilder("\"");
			string text = s ?? "";
			foreach (char c in text)
			{
				if (c == '"' || c == '\\')
				{
					stringBuilder.Append('\\').Append(c);
				}
				else if (c < ' ' || char.IsSurrogate(c))
				{
					StringBuilder stringBuilder2 = stringBuilder.Append("\\u");
					int num = c;
					stringBuilder2.Append(num.ToString("x4"));
				}
				else
				{
					stringBuilder.Append(c);
				}
			}
			return stringBuilder.Append('"').ToString();
		}

		internal static string Embed(string title, string description, int colour, params KeyValuePair<string, string>[] fields)
		{
			return "{\"username\":\"Clanheim • The Watchtower\",\"allowed_mentions\":{\"parse\":[]},\"embeds\":[{\"title\":" + Json(Limit(title, 256)) + ",\"description\":" + Json(Limit(description, 2200)) + ",\"color\":" + colour + ",\"timestamp\":" + Json(DateTime.UtcNow.ToString("o")) + ",\"footer\":{\"text\":\"CLANHEIM | Alba meets Asgard • Server Watch 1.0.1\"},\"fields\":[" + string.Join(",", from f in fields.Take(12)
				select "{\"name\":" + Json(Limit(f.Key, 256)) + ",\"value\":" + Json(Limit(string.IsNullOrEmpty(f.Value) ? "Unknown" : f.Value, 1024)) + ",\"inline\":true}") + "]}]}";
		}

		internal static KeyValuePair<string, string> Field(string name, string value)
		{
			return new KeyValuePair<string, string>(name, value);
		}

		internal static bool Webhook(string input, out string canonical)
		{
			canonical = "";
			if (!Uri.TryCreate(input, UriKind.Absolute, out Uri result) || result.Scheme != "https" || result.Host != "discord.com" || !result.IsDefaultPort || result.UserInfo != "" || result.Query != "" || result.Fragment != "")
			{
				return false;
			}
			Match match = Regex.Match(result.AbsolutePath, "^/api/(?:v10/)?webhooks/(\\d{10,25})/([A-Za-z0-9_-]{20,200})/?$");
			if (!match.Success)
			{
				return false;
			}
			canonical = "https://discord.com/api/v10/webhooks/" + match.Groups[1].Value + "/" + match.Groups[2].Value;
			return true;
		}
	}
	internal sealed class Health
	{
		private readonly object gate = new object();

		private readonly Dictionary<string, long> counts = new Dictionary<string, long>();

		internal long Warnings;

		internal long Errors;

		internal string Season = "Not observed";

		internal string Save = "No save observed this session";

		internal long CompletedSaves;

		internal void Observe(string source, string message, bool warning, bool error)
		{
			if (source == "Clanheim Server Watch")
			{
				return;
			}
			lock (gate)
			{
				if (source.Trim() == "Seasons")
				{
					Match match = Regex.Match(message, "(?:Season: |Current: )(Spring|Summer|Autumn|Fall|Winter)(?:, day: | )(\\d+)", RegexOptions.IgnoreCase);
					if (match.Success)
					{
						Season = match.Groups[1].Value + " • day " + match.Groups[2].Value;
					}
				}
				if (source == "Unity Log")
				{
					if (message.Contains("PrepareSave:") || message.Contains("Sending message to save player profiles"))
					{
						Save = "Preparation observed at " + DateTime.UtcNow.ToString("HH:mm 'UTC'");
					}
					if (!warning && !error && Regex.IsMatch(message, "(?:^|: )(?:World saved\\b|World save finished\\b)", RegexOptions.IgnoreCase))
					{
						Save = "Completion logged at " + DateTime.UtcNow.ToString("HH:mm 'UTC'");
						CompletedSaves++;
					}
				}
				if (warning || error)
				{
					if (error)
					{
						Errors++;
					}
					else
					{
						Warnings++;
					}
					string key = ((source.Contains("Valheim Villages") && message.Contains("hna_partition")) ? "Village processing timeouts" : ((source.Contains("Valheim Villages") && message.Contains("Graph was committed inconsistent")) ? "Village region inconsistencies" : (source.Contains("Valheim Villages") ? "Other village warnings/errors" : (message.Contains("as a world modifier") ? "Rejected world modifiers" : (message.Contains("duplicate of it was already loaded") ? "Duplicate plugins skipped" : (message.Contains("Enchanting is on but Loot rarity is off") ? "Enchanting / loot configuration mismatch" : ((message.IndexOf("Exception", StringComparison.OrdinalIgnoreCase) >= 0) ? "Exceptions (inspect server log)" : ((message.IndexOf("shader", StringComparison.OrdinalIgnoreCase) < 0 && !message.Contains("AsyncResourceUpload")) ? (error ? "Other errors (inspect server log)" : "Other warnings (inspect server log)") : "Rendering / asset warnings"))))))));
					counts.TryGetValue(key, out var value);
					counts[key] = value + 1;
				}
			}
		}

		internal HealthSnapshot Snapshot()
		{
			lock (gate)
			{
				return new HealthSnapshot
				{
					Season = Season,
					Save = Save,
					Warnings = Warnings,
					Errors = Errors,
					CompletedSaves = CompletedSaves
				};
			}
		}

		internal string Drain()
		{
			lock (gate)
			{
				string result = string.Join("\n", from x in counts
					orderby x.Value descending
					select "• " + x.Key + ": **" + x.Value + "**");
				counts.Clear();
				return result;
			}
		}
	}
	internal sealed class HealthSnapshot
	{
		internal string Season;

		internal string Save;

		internal long Warnings;

		internal long Errors;

		internal long CompletedSaves;
	}
	internal static class Players
	{
		internal static List<string> Added(List<string> before, List<string> after)
		{
			List<string> list = new List<string>(before);
			List<string> list2 = new List<string>();
			foreach (string item in after)
			{
				if (!list.Remove(item))
				{
					list2.Add(item);
				}
			}
			return list2;
		}
	}
	internal static class Announcements
	{
		internal static string Take(string directory)
		{
			string text = Path.Combine(directory, "announcement.txt");
			if (!File.Exists(text))
			{
				return null;
			}
			FileInfo fileInfo = new FileInfo(text);
			if (fileInfo.Length > 8192)
			{
				return null;
			}
			if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc < TimeSpan.FromSeconds(2.0))
			{
				return null;
			}
			string text2 = Text.Safe(File.ReadAllText(text), 1800);
			if (text2.Length == 0)
			{
				return null;
			}
			string text3 = Path.Combine(directory, "announcement-last-queued.txt");
			if (File.Exists(text3))
			{
				File.Delete(text3);
			}
			File.Move(text, text3);
			return text2;
		}
	}
	internal sealed class GameReader
	{
		private const BindingFlags Flags = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;

		private readonly Dictionary<string, Type> types = new Dictionary<string, Type>();

		internal Type Type(string name)
		{
			if (types.TryGetValue(name, out var value))
			{
				return value;
			}
			Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies();
			for (int i = 0; i < assemblies.Length; i++)
			{
				value = assemblies[i].GetType(name, throwOnError: false);
				if (value != null)
				{
					types[name] = value;
					return value;
				}
			}
			return null;
		}

		internal static object Get(Type type, object instance, string name)
		{
			if (type == null)
			{
				return null;
			}
			FieldInfo field = type.GetField(name, BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
			if (field != null)
			{
				return field.GetValue(instance);
			}
			PropertyInfo property = type.GetProperty(name, BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
			if (!(property != null) || property.GetIndexParameters().Length != 0)
			{
				return null;
			}
			return property.GetValue(instance, null);
		}

		internal static object Call(Type type, object instance, string name)
		{
			if (type == null)
			{
				return null;
			}
			MethodInfo[] array = (from m in type.GetMethods(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)
				where m.Name == name && m.GetParameters().Length == 0 && !m.ContainsGenericParameters
				select m).ToArray();
			if (array.Length != 1)
			{
				return null;
			}
			return array[0].Invoke(instance, null);
		}

		internal object Instance(string type)
		{
			return Get(Type(type), null, "instance");
		}

		internal bool Ready()
		{
			try
			{
				object obj = Instance("ZNet");
				return obj != null && object.Equals(Call(obj.GetType(), obj, "IsServer"), true) && Instance("ZNetScene") != null && Instance("EnvMan") != null;
			}
			catch
			{
				return false;
			}
		}

		internal bool TryPlayers(out List<string> players)
		{
			players = new List<string>();
			try
			{
				object obj = Instance("ZNet");
				IEnumerable enumerable = ((obj == null) ? null : (Call(obj.GetType(), obj, "GetPlayerList") as IEnumerable));
				if (enumerable == null)
				{
					return false;
				}
				foreach (object item2 in enumerable)
				{
					if (!(Get(item2.GetType(), item2, "m_name") is string item))
					{
						return false;
					}
					players.Add(item);
				}
				return true;
			}
			catch
			{
				return false;
			}
		}

		internal string Day()
		{
			try
			{
				object obj = Instance("EnvMan");
				return (obj == null) ? "Unknown" : (Convert.ToString(Call(obj.GetType(), obj, "GetCurrentDay")) ?? "Unknown");
			}
			catch
			{
				return "Unknown";
			}
		}

		internal string World()
		{
			try
			{
				Type type = Type("ZNet");
				object instance = Instance("ZNet");
				string text = Call(type, instance, "GetWorldName") as string;
				if (!string.IsNullOrEmpty(text))
				{
					return Text.Safe(text);
				}
				object obj = Get(type, null, "World") ?? Get(type, null, "m_world");
				return (obj == null) ? "Unknown" : Text.Safe(Convert.ToString(Get(obj.GetType(), obj, "m_name")));
			}
			catch
			{
				return "Unknown";
			}
		}

		internal string Version()
		{
			try
			{
				return Text.Safe(Convert.ToString(Call(Type("Version"), null, "GetVersionString")));
			}
			catch
			{
				return "Unknown";
			}
		}
	}
	[BepInPlugin("com.clanheim.serverwatch", "Clanheim Server Watch", "1.0.1")]
	public sealed class Plugin : BaseUnityPlugin, ILogListener, IDisposable
	{
		private readonly Health health = new Health();

		private readonly GameReader game = new GameReader();

		private readonly Stopwatch clock = Stopwatch.StartNew();

		private readonly ConcurrentQueue<string> diagnostics = new ConcurrentQueue<string>();

		private Discord discord;

		private string publicUrl = "";

		private string adminUrl = "";

		private string eventsUrl = "";

		private string announcementsUrl = "";

		private string joinAddress = "";

		private string joinInstructions = "";

		private string modpackUrl = "";

		private int refresh;

		private int digestSeconds;

		private int capacity;

		private bool enabledMod;

		private bool showNames;

		private bool notifyPlayers;

		private bool notifySaves;

		private bool active;

		private bool finished;

		private bool listening;

		private double nextPoll;

		private double nextPanel;

		private double nextDigest;

		private double readyAt = -1.0;

		private double frameSum;

		private long frameCount;

		private long completedSaves;

		private string frameRate = "Warming up";

		private string playerCount = "Unknown";

		private List<string> previous = new List<string>();

		private bool havePlayers;

		private long joins;

		private long departures;

		private int peak;

		private bool announceLifecycle;

		private void Awake()
		{
			if (!Path.GetFileNameWithoutExtension(Environment.GetCommandLineArgs()[0]).StartsWith("valheim_server", StringComparison.OrdinalIgnoreCase))
			{
				((BaseUnityPlugin)this).Logger.LogInfo((object)"Clanheim Server Watch is dedicated-server only; inactive on this process.");
				return;
			}
			enabledMod = ((BaseUnityPlugin)this).Config.Bind<bool>("General", "Enabled", true, "Enable Clanheim Server Watch. Restart after any config edit.").Value;
			string text = ((BaseUnityPlugin)this).Config.Bind<string>("Discord", "StatusWebhookUrl", "", "Discord webhook for the continuously edited status panel only. Keep private.").Value.Trim();
			string text2 = ((BaseUnityPlugin)this).Config.Bind<string>("Discord", "AdminWebhookUrl", "", "Optional private admin-channel webhook. Blank disables error digests and save notifications; no public fallback.").Value.Trim();
			string text3 = ((BaseUnityPlugin)this).Config.Bind<string>("Discord", "EventsWebhookUrl", "", "Webhook for player joins/disconnects. Blank disables these posts; no status-channel fallback.").Value.Trim();
			string text4 = ((BaseUnityPlugin)this).Config.Bind<string>("Discord", "AnnouncementsWebhookUrl", "", "Webhook for server startup/shutdown and custom announcements. Blank disables these posts.").Value.Trim();
			refresh = Math.Max(30, Math.Min(900, ((BaseUnityPlugin)this).Config.Bind<int>("Status", "RefreshSeconds", 60, "Status panel update interval, 30-900 seconds.").Value));
			digestSeconds = Math.Max(60, Math.Min(3600, ((BaseUnityPlugin)this).Config.Bind<int>("Alerts", "DigestSeconds", 600, "Group warning/error counts over this interval, 60-3600 seconds.").Value));
			capacity = Math.Max(1, Math.Min(1000, ((BaseUnityPlugin)this).Config.Bind<int>("Status", "PlayerCapacity", 10, "Display-only capacity; does not change the game player limit.").Value));
			showNames = ((BaseUnityPlugin)this).Config.Bind<bool>("Status", "ShowPlayerNames", true, "Show character names in the public panel and player events. No account IDs are sent.").Value;
			notifyPlayers = ((BaseUnityPlugin)this).Config.Bind<bool>("Events", "PlayerNotifications", true, "Post joins/leaves detected by five-second player-list polling.").Value;
			notifySaves = ((BaseUnityPlugin)this).Config.Bind<bool>("Events", "SaveNotifications", false, "Send admin-channel notifications only when a recognised world-save completion log is observed.").Value;
			joinAddress = Text.Safe(((BaseUnityPlugin)this).Config.Bind<string>("Status", "JoinAddress", "", "Optional public server address shown in Discord. Never enter a password.").Value, 150);
			joinInstructions = Text.Safe(((BaseUnityPlugin)this).Config.Bind<string>("Status", "JoinInstructions", "Install the official Clanheim modpack in Thunderstore Mod Manager. Launch Valheim with Start Modded, then join Clanheim using the server address. Ask staff for access details.", "Public how-to-join instructions; never enter passwords. Maximum 600 characters.").Value, 600);
			modpackUrl = ((BaseUnityPlugin)this).Config.Bind<string>("Status", "ModpackUrl", "", "Optional public HTTPS link to the official modpack. Maximum 250 characters.").Value.Trim();
			if (modpackUrl.Length > 250 || !Uri.TryCreate(modpackUrl, UriKind.Absolute, out Uri result) || result.Scheme != "https" || result.UserInfo.Length != 0)
			{
				modpackUrl = "";
			}
			announceLifecycle = ((BaseUnityPlugin)this).Config.Bind<bool>("Events", "ServerAnnouncements", true, "Post startup and best-effort shutdown announcements to AnnouncementsWebhookUrl.").Value;
			if (!enabledMod)
			{
				((BaseUnityPlugin)this).Logger.LogInfo((object)"Clanheim Server Watch is disabled.");
				return;
			}
			if (text.Length > 0 && !Text.Webhook(text, out publicUrl))
			{
				((BaseUnityPlugin)this).Logger.LogWarning((object)"Status webhook is invalid. Use a plain https://discord.com/api/webhooks/... URL; no thread query. URL withheld.");
			}
			if (text2.Length > 0 && !Text.Webhook(text2, out adminUrl))
			{
				((BaseUnityPlugin)this).Logger.LogWarning((object)"Admin webhook is invalid. URL withheld.");
			}
			if (text3.Length > 0 && !Text.Webhook(text3, out eventsUrl))
			{
				((BaseUnityPlugin)this).Logger.LogWarning((object)"Events webhook is invalid. URL withheld.");
			}
			if (text4.Length > 0 && !Text.Webhook(text4, out announcementsUrl))
			{
				((BaseUnityPlugin)this).Logger.LogWarning((object)"Announcements webhook is invalid. URL withheld.");
			}
			if (publicUrl.Length == 0 && adminUrl.Length == 0 && eventsUrl.Length == 0 && announcementsUrl.Length == 0)
			{
				((BaseUnityPlugin)this).Logger.LogInfo((object)"Setup needed: edit BepInEx/config/com.clanheim.serverwatch.cfg, add a Discord webhook, then restart.");
				return;
			}
			discord = new Discord(Path.Combine(Paths.ConfigPath, "ClanheimServerWatch", "status-message.state"), delegate(string s)
			{
				diagnostics.Enqueue(s);
			});
			try
			{
				Directory.CreateDirectory(Path.Combine(Paths.ConfigPath, "ClanheimServerWatch"));
			}
			catch
			{
				((BaseUnityPlugin)this).Logger.LogWarning((object)"Could not create the announcement folder. Custom file announcements need a writable BepInEx/config/ClanheimServerWatch folder.");
			}
			Logger.Listeners.Add((ILogListener)(object)this);
			listening = true;
			nextDigest = digestSeconds;
			((BaseUnityPlugin)this).Logger.LogInfo((object)"Clanheim Server Watch 1.0.1 ready. Status, player events, announcements and admin alerts use separate configured routes.");
		}

		private void Update()
		{
			if (discord == null || finished)
			{
				return;
			}
			for (int i = 0; i < 4; i++)
			{
				if (!diagnostics.TryDequeue(out var result))
				{
					break;
				}
				((BaseUnityPlugin)this).Logger.LogWarning((object)result);
			}
			double totalSeconds = clock.Elapsed.TotalSeconds;
			frameSum += Time.unscaledDeltaTime;
			frameCount++;
			if (totalSeconds < nextPoll)
			{
				return;
			}
			nextPoll = totalSeconds + 5.0;
			try
			{
				PollAnnouncement();
				if (!game.Ready())
				{
					readyAt = -1.0;
					if (active && totalSeconds >= nextPanel)
					{
						discord.Enqueue(publicUrl, Text.Embed("CLANHEIM | World unavailable", "The process is running, but the world reader is not ready. Last heartbeat below; investigate if this persists.", 15105570), panel: true);
						nextPanel = totalSeconds + (double)refresh;
					}
					FlushDigest(totalSeconds);
					return;
				}
				if (readyAt < 0.0)
				{
					readyAt = totalSeconds;
				}
				if (totalSeconds - readyAt < 20.0)
				{
					FlushDigest(totalSeconds);
					return;
				}
				if (frameSum > 0.0)
				{
					frameRate = ((double)frameCount / frameSum).ToString("0.0") + " FPS (main loop)";
				}
				frameSum = 0.0;
				frameCount = 0L;
				if (game.TryPlayers(out var players))
				{
					playerCount = players.Count + " / " + capacity;
					peak = Math.Max(peak, players.Count);
					if (havePlayers)
					{
						List<string> list = Players.Added(previous, players);
						List<string> list2 = Players.Added(players, previous);
						joins += list.Count;
						departures += list2.Count;
						if (list.Count > 0 || list2.Count > 0)
						{
							nextPanel = Math.Min(nextPanel, totalSeconds + 1.0);
						}
						if (notifyPlayers && list.Count > 0)
						{
							PlayerEvent(list, joined: true);
						}
						if (notifyPlayers && list2.Count > 0)
						{
							PlayerEvent(list2, joined: false);
						}
					}
					previous = players;
					havePlayers = true;
				}
				else
				{
					playerCount = "Unavailable";
				}
				if (!active)
				{
					active = true;
					if (announceLifecycle)
					{
						discord.Enqueue(announcementsUrl, Text.Embed("The watchfires are lit", "**Clanheim is running.** The world reader is ready. Gather your clan and begin your next voyage. This is a server heartbeat, not an external connectivity test.", 3066993));
					}
					((BaseUnityPlugin)this).Logger.LogInfo((object)"World ready. Discord monitoring started.");
				}
				HealthSnapshot healthSnapshot = health.Snapshot();
				if (notifySaves && healthSnapshot.CompletedSaves > completedSaves)
				{
					discord.Enqueue(adminUrl, Text.Embed("Clanheim | World save", healthSnapshot.Save + ". Confirmation comes from the game log.", 3447003));
				}
				completedSaves = healthSnapshot.CompletedSaves;
				if (totalSeconds >= nextPanel)
				{
					discord.Enqueue(publicUrl, Panel(healthSnapshot), panel: true);
					nextPanel = totalSeconds + (double)refresh;
				}
				FlushDigest(totalSeconds);
			}
			catch
			{
				((BaseUnityPlugin)this).Logger.LogWarning((object)"A server data sample failed; monitoring will retry. Gameplay has not been modified.");
			}
		}

		private string Panel(HealthSnapshot h)
		{
			List<KeyValuePair<string, string>> list = new List<KeyValuePair<string, string>>
			{
				Text.Field("Warriors in the realm", playerCount),
				Text.Field("Watch uptime", clock.Elapsed.ToString("d\\d\\ hh\\h\\ mm\\m")),
				Text.Field("World", game.World()),
				Text.Field("World day", game.Day()),
				Text.Field("Season (last observed)", h.Season),
				Text.Field("Server loop", frameRate),
				Text.Field("World save (log observation)", h.Save),
				Text.Field("Game / loaded plugins", game.Version() + " / " + Chainloader.PluginInfos.Count)
			};
			list.Add(Text.Field("This watch", "Peak: " + peak + " • Joins: " + joins + " • Disconnects: " + departures));
			if (showNames)
			{
				list.Add(Text.Field("Around the hearth", (playerCount == "Unavailable") ? "Player list unavailable" : ((previous.Count == 0) ? "The hearth is quiet" : string.Join(" • ", previous.Select((string n) => Text.Safe(n, 64))))));
			}
			string text = ((joinAddress.Length > 0) ? ("Server: " + joinAddress + "\n") : "") + joinInstructions + ((modpackUrl.Length > 0) ? ("\nModpack: " + modpackUrl) : "");
			if (text.Length > 0)
			{
				list.Add(Text.Field("How to join Clanheim", text));
			}
			return Text.Embed("CLANHEIM | The Watchtower", "\ud83d\udfe2 **Server heartbeat received**\nScottish spirit. Viking resolve.\nUpdates every " + refresh + " seconds. If this timestamp is over " + refresh * 3 + " seconds old, status is **unknown**—the server or Discord connection may be down.", 3066993, list.ToArray());
		}

		private void PlayerEvent(List<string> names, bool joined)
		{
			string s = (showNames ? string.Join(", ", names.Select((string n) => Text.Safe(n, 64))) : (names.Count + ((names.Count == 1) ? " warrior" : " warriors")));
			discord.Enqueue(eventsUrl, Text.Embed(joined ? "A warrior joins the clan" : "A warrior disconnects", Text.Limit(s, 1000) + (joined ? " joined Clanheim. The hearth has a place for you." : " disconnected from Clanheim. Until the next voyage."), joined ? 3066993 : 9807270, Text.Field("Players online", playerCount)));
		}

		private void PollAnnouncement()
		{
			if (announcementsUrl.Length != 0)
			{
				string text = Announcements.Take(Path.Combine(Paths.ConfigPath, "ClanheimServerWatch"));
				if (text != null)
				{
					discord.Enqueue(announcementsUrl, Text.Embed("Clanheim | From the Great Hall", text, 15844367));
					((BaseUnityPlugin)this).Logger.LogInfo((object)"Custom server announcement queued for Discord; announcement-last-queued.txt is a local copy, not a delivery receipt.");
				}
			}
		}

		private void FlushDigest(double now)
		{
			if (!(now < nextDigest))
			{
				nextDigest = now + (double)digestSeconds;
				string text = health.Drain();
				if (text.Length != 0)
				{
					HealthSnapshot healthSnapshot = health.Snapshot();
					discord.Enqueue(adminUrl, Text.Embed("Clanheim | Watchtower report", "Grouped alerts from the last " + digestSeconds + " seconds:\n\n" + text + "\n\nThese are log counts, not a diagnosis. Full details stay in the server log.", (healthSnapshot.Errors > 0) ? 15158332 : 15105570, Text.Field("Since monitor loaded", healthSnapshot.Errors + " errors • " + healthSnapshot.Warnings + " warnings"), Text.Field("Discord updates dropped", discord.Dropped.ToString())));
				}
			}
		}

		public void LogEvent(object sender, LogEventArgs e)
		{
			//IL_0026: Unknown result type (might be due to invalid IL or missing references)
			//IL_002c: Unknown result type (might be due to invalid IL or missing references)
			//IL_002e: Invalid comparison between Unknown and I4
			//IL_0031: Unknown result type (might be due to invalid IL or missing references)
			//IL_0037: Unknown result type (might be due to invalid IL or missing references)
			//IL_0039: Invalid comparison between Unknown and I4
			try
			{
				health.Observe(e.Source.SourceName, Convert.ToString(e.Data) ?? "", (e.Level & 4) > 0, (e.Level & 3) > 0);
			}
			catch
			{
			}
		}

		private void OnApplicationQuit()
		{
			Shutdown();
		}

		private void OnDestroy()
		{
			Shutdown();
		}

		private void Shutdown()
		{
			if (!finished)
			{
				finished = true;
				if (listening)
				{
					Logger.Listeners.Remove((ILogListener)(object)this);
					listening = false;
				}
				if (discord != null)
				{
					string text = Text.Embed("CLANHEIM | The watch is ending", "\ud83d\udfe0 **Shutdown observed**\nThe server process is closing or the monitor is unloading. This is a best-effort notice; it does not confirm the final save completed.", 15105570);
					discord.Finish(publicUrl, text, 3000, announceLifecycle ? announcementsUrl : "", text);
					discord.Dispose();
				}
			}
		}

		public void Dispose()
		{
		}
	}
	internal sealed class Delivery
	{
		internal string Url;

		internal string Body;

		internal bool Status;
	}
	internal sealed class Discord : IDisposable
	{
		private readonly HttpClient http;

		private readonly object gate = new object();

		private readonly Queue<Delivery> queue = new Queue<Delivery>();

		private readonly HashSet<string> blocked = new HashSet<string>();

		private readonly Dictionary<string, DateTime> cooldown = new Dictionary<string, DateTime>();

		private readonly CancellationTokenSource stop = new CancellationTokenSource();

		private readonly Action<string> diagnostic;

		private readonly string statePath;

		private readonly Func<TimeSpan, CancellationToken, Task> delay;

		private readonly Task worker;

		private Delivery status;

		private string statusId = "";

		private string statusHash = "";

		private DateTime globalCooldown = DateTime.MinValue;

		private bool closing;

		private long dropped;

		internal long Dropped => Interlocked.Read(in dropped);

		internal Discord(string path, Action<string> log, HttpMessageHandler handler = null, Func<TimeSpan, CancellationToken, Task> delayOverride = null, bool startWorker = true)
		{
			statePath = path;
			diagnostic = log;
			http = new HttpClient(handler ?? new HttpClientHandler
			{
				AllowAutoRedirect = false
			});
			http.Timeout = TimeSpan.FromSeconds(10.0);
			http.DefaultRequestHeaders.UserAgent.ParseAdd("ClanheimServerWatch/1.0.1");
			delay = delayOverride ?? ((Func<TimeSpan, CancellationToken, Task>)((TimeSpan t, CancellationToken c) => Task.Delay(t, c)));
			try
			{
				if (File.Exists(path))
				{
					string[] array = File.ReadAllLines(path);
					if (array.Length == 2 && Regex.IsMatch(array[0], "^[a-f0-9]{64}$") && Regex.IsMatch(array[1], "^[0-9]{10,25}$"))
					{
						statusHash = array[0];
						statusId = array[1];
					}
				}
			}
			catch
			{
				diagnostic("Could not read status-message state; a new panel may be created.");
			}
			worker = (startWorker ? Task.Run((Func<Task?>)Loop) : Task.CompletedTask);
		}

		internal void Enqueue(string url, string body, bool panel = false)
		{
			if (string.IsNullOrEmpty(url))
			{
				return;
			}
			lock (gate)
			{
				if (!closing)
				{
					Delivery item = new Delivery
					{
						Url = url,
						Body = body,
						Status = panel
					};
					if (panel)
					{
						status = item;
					}
					else if (queue.Count < 100)
					{
						queue.Enqueue(item);
					}
					else
					{
						Interlocked.Increment(ref dropped);
					}
				}
			}
		}

		private async Task Loop()
		{
			_ = 1;
			try
			{
				while (!stop.IsCancellationRequested)
				{
					Delivery next = null;
					lock (gate)
					{
						if (status != null)
						{
							next = status;
							status = null;
						}
						else if (queue.Count > 0)
						{
							next = queue.Dequeue();
						}
						else if (closing)
						{
							break;
						}
					}
					if (next != null)
					{
						await Send(next, stop.Token).ConfigureAwait(continueOnCapturedContext: false);
					}
					await delay(TimeSpan.FromMilliseconds((next == null) ? 200 : 750), stop.Token).ConfigureAwait(continueOnCapturedContext: false);
				}
			}
			catch (OperationCanceledException)
			{
			}
			catch
			{
				diagnostic("Discord worker stopped unexpectedly. Restart Server Watch to reconnect.");
			}
		}

		private static string Hash(string url)
		{
			using SHA256 sHA = SHA256.Create();
			return BitConverter.ToString(sHA.ComputeHash(Encoding.UTF8.GetBytes(url))).Replace("-", "").ToLowerInvariant();
		}

		internal async Task Send(Delivery d, CancellationToken token)
		{
			if (blocked.Contains(d.Url))
			{
				Interlocked.Increment(ref dropped);
				return;
			}
			if (globalCooldown > DateTime.UtcNow)
			{
				await delay(globalCooldown - DateTime.UtcNow, token).ConfigureAwait(continueOnCapturedContext: false);
			}
			if (cooldown.TryGetValue(d.Url, out var value) && value > DateTime.UtcNow)
			{
				await delay(value - DateTime.UtcNow, token).ConfigureAwait(continueOnCapturedContext: false);
			}
			if (d.Status && statusHash != Hash(d.Url))
			{
				statusId = "";
				statusHash = Hash(d.Url);
			}
			for (int attempt = 0; attempt < 4; attempt++)
			{
				bool edit = d.Status && statusId.Length > 0;
				string requestUri = d.Url + (edit ? ("/messages/" + statusId) : "?wait=true");
				try
				{
					using HttpRequestMessage request = new HttpRequestMessage(edit ? new HttpMethod("PATCH") : HttpMethod.Post, requestUri);
					request.Content = new StringContent(d.Body, Encoding.UTF8, "application/json");
					using (HttpResponseMessage response = await http.SendAsync(request, token).ConfigureAwait(continueOnCapturedContext: false))
					{
						string text = await response.Content.ReadAsStringAsync().ConfigureAwait(continueOnCapturedContext: false);
						int statusCode = (int)response.StatusCode;
						if (statusCode >= 200 && statusCode < 300)
						{
							if (d.Status && !edit)
							{
								statusId = ResponseId(text);
								if (statusId.Length == 0)
								{
									blocked.Add(d.Url);
									diagnostic("Discord accepted a panel but returned no message ID. Sender paused to avoid duplicate panels; restart after checking Discord.");
								}
								else
								{
									SaveState();
								}
							}
							ReadCooldown(response, d.Url);
							return;
						}
						if (statusCode == 404 && edit)
						{
							statusId = "";
							continue;
						}
						if (statusCode == 401 || statusCode == 403 || statusCode == 404)
						{
							blocked.Add(d.Url);
							diagnostic("Discord webhook rejected (HTTP " + statusCode + "). Check the configured webhook and restart. URL withheld.");
							Interlocked.Increment(ref dropped);
							return;
						}
						if (statusCode == 429)
						{
							double value2 = RetrySeconds(text, response);
							cooldown[d.Url] = DateTime.UtcNow.AddSeconds(value2);
							if (Regex.IsMatch(text, "\"global\"\\s*:\\s*true"))
							{
								globalCooldown = cooldown[d.Url];
							}
							if (attempt < 3)
							{
								await delay(TimeSpan.FromSeconds(value2), token).ConfigureAwait(continueOnCapturedContext: false);
							}
							continue;
						}
						if (statusCode < 500)
						{
							diagnostic("Discord rejected a payload (HTTP " + statusCode + "). No payload or secrets logged.");
							break;
						}
					}
					goto IL_0649;
				}
				catch (OperationCanceledException) when (token.IsCancellationRequested)
				{
					throw;
				}
				catch
				{
					goto IL_0649;
				}
				IL_0649:
				if (attempt < 3)
				{
					await delay(TimeSpan.FromSeconds(Math.Pow(2.0, attempt + 1)), token).ConfigureAwait(continueOnCapturedContext: false);
				}
			}
			Interlocked.Increment(ref dropped);
			diagnostic("Discord delivery failed after retries; this update was dropped. Future updates will retry normally.");
		}

		internal static double RetrySeconds(string body, HttpResponseMessage response)
		{
			Match match = Regex.Match(body ?? "", "\"retry_after\"\\s*:\\s*([0-9]+(?:\\.[0-9]+)?)");
			if (match.Success && double.TryParse(match.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var result) && result < 86400.0)
			{
				return Math.Max(1.0, result) + 0.25;
			}
			if (response.Headers.RetryAfter != null && response.Headers.RetryAfter.Delta.HasValue)
			{
				return Math.Max(1.0, response.Headers.RetryAfter.Delta.Value.TotalSeconds) + 0.25;
			}
			return 60.0;
		}

		private void ReadCooldown(HttpResponseMessage response, string url)
		{
			if (response.Headers.TryGetValues("X-RateLimit-Remaining", out IEnumerable<string> values) && !(string.Join("", values) != "0") && response.Headers.TryGetValues("X-RateLimit-Reset-After", out values) && double.TryParse(string.Join("", values), NumberStyles.Float, CultureInfo.InvariantCulture, out var result) && result >= 0.0 && result < 86400.0)
			{
				cooldown[url] = DateTime.UtcNow.AddSeconds(result + 0.25);
			}
		}

		internal static string ResponseId(string json)
		{
			int num = 0;
			for (int i = 0; i < json.Length; i++)
			{
				switch (json[i])
				{
				case '[':
				case '{':
					num++;
					break;
				case ']':
				case '}':
					num--;
					break;
				case '"':
				{
					int num2 = ++i;
					for (; i < json.Length; i++)
					{
						if (json[i] == '\\')
						{
							i++;
						}
						else if (json[i] == '"')
						{
							break;
						}
					}
					if (num == 1 && i - num2 == 2 && json.Substring(num2, 2) == "id")
					{
						Match match = Regex.Match(json.Substring(Math.Min(i + 1, json.Length)), "^\\s*:\\s*\"([0-9]{10,25})\"");
						if (match.Success)
						{
							return match.Groups[1].Value;
						}
					}
					break;
				}
				}
			}
			return "";
		}

		private void SaveState()
		{
			try
			{
				Directory.CreateDirectory(Path.GetDirectoryName(statePath));
				File.WriteAllText(statePath + ".tmp", statusHash + "\n" + statusId);
				if (File.Exists(statePath))
				{
					File.Delete(statePath);
				}
				File.Move(statePath + ".tmp", statePath);
			}
			catch
			{
				diagnostic("Could not save Discord panel identity. It may be recreated after a restart.");
			}
		}

		internal void Finish(string url, string finalPanel, int milliseconds, string announcementUrl = "", string announcementBody = "")
		{
			lock (gate)
			{
				queue.Clear();
				if (!string.IsNullOrEmpty(announcementUrl))
				{
					queue.Enqueue(new Delivery
					{
						Url = announcementUrl,
						Body = announcementBody
					});
				}
				status = null;
				if (!string.IsNullOrEmpty(url))
				{
					queue.Enqueue(new Delivery
					{
						Url = url,
						Body = finalPanel,
						Status = true
					});
				}
				closing = true;
			}
			try
			{
				worker.Wait(milliseconds);
			}
			catch
			{
			}
			stop.Cancel();
		}

		public void Dispose()
		{
			stop.Cancel();
			worker.ContinueWith(delegate
			{
				http.Dispose();
				stop.Dispose();
			}, TaskScheduler.Default);
		}
	}
}