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 Clanheim Server Watch v1.0.1
BepInEx/plugins/ClanheimServerWatch/ClanheimServerWatch.dll
Decompiled a day agousing 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); } } }