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 FiresDiscordIntegration v1.0.34
plugins/FiresDiscordIntegration/FiresDiscordIntegration.dll
Decompiled 2 weeks ago
The result has been truncated due to the large size, download it to view full contents!
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; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Security; using System.Security.Permissions; using System.Text; using System.Text.RegularExpressions; using System.Threading; using BepInEx; using BepInEx.Configuration; using BepInEx.Logging; using FiresCore.Async; using FiresCore.Bridge; using FiresCore.ClientLogRelay; using FiresCore.ClientLogRelay.Interactions; using FiresCore.ClientLogRelay.Transport; using FiresCore.Config; using FiresCore.Help; using FiresCore.Lifecycle; using FiresCore.Logging; using FiresCore.Sync; using FiresDiscordIntegration.ChatRelay; using FiresDiscordIntegration.ClientLogRelay; using FiresDiscordIntegration.ClientLogRelay.Consumers; using FiresDiscordIntegration.ClientLogRelay.Interactions; using FiresDiscordIntegration.ClientLogRelay.Webhook; using FiresDiscordIntegration.Discord; using FiresDiscordIntegration.Utilities; using FiresLogAnalysis; using FiresLogAnalysis.Problems; using GUIFramework; using HarmonyLib; using Microsoft.CodeAnalysis; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Splatform; using TMPro; using UnityEngine; using UnityEngine.Networking; using UnityEngine.UI; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)] [assembly: AssemblyTitle("FiresDiscordIntegration")] [assembly: AssemblyDescription("")] [assembly: AssemblyConfiguration("")] [assembly: AssemblyCompany("")] [assembly: AssemblyProduct("FiresDiscordIntegration")] [assembly: AssemblyCopyright("Copyright © 2025")] [assembly: AssemblyTrademark("")] [assembly: ComVisible(false)] [assembly: Guid("c1f16343-d521-42e6-a7a7-1b3aa4d63f4c")] [assembly: AssemblyFileVersion("1.0.4.0")] [assembly: TargetFramework(".NETFramework,Version=v4.8", FrameworkDisplayName = ".NET Framework 4.8")] [assembly: SecurityPermission(SecurityAction.RequestMinimum, SkipVerification = true)] [assembly: AssemblyVersion("1.0.4.0")] [module: UnverifiableCode] [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 FiresDiscordIntegration { [BepInPlugin("com.Fire.FiresDiscordIntegration", "FiresDiscordIntegration", "1.0.34")] [BepInDependency(/*Could not decode attribute arguments.*/)] [BepInDependency(/*Could not decode attribute arguments.*/)] public class FiresDiscordIntegrationPlugin : BaseUnityPlugin { public const string PluginGUID = "com.Fire.FiresDiscordIntegration"; public const string PluginName = "FiresDiscordIntegration"; public const string PluginVersion = "1.0.34"; public static FiresDiscordIntegrationPlugin Instance; public static readonly Harmony Harmony = new Harmony("com.Fire.FiresDiscordIntegration"); internal static ManualLogSource Log; public ConfigSync configSync; private void Awake() { //IL_0036: Unknown result type (might be due to invalid IL or missing references) //IL_003b: Unknown result type (might be due to invalid IL or missing references) //IL_0046: Unknown result type (might be due to invalid IL or missing references) //IL_0051: Unknown result type (might be due to invalid IL or missing references) //IL_0061: Expected O, but got Unknown Log = ((BaseUnityPlugin)this).Logger; try { FiresDiscordBanner.PrintBig(); } catch (Exception ex) { Debug.LogWarning((object)("[FiresDiscordIntegration] Big banner failed: " + ex.Message)); } Instance = this; configSync = new ConfigSync("com.Fire.FiresDiscordIntegration") { DisplayName = "FiresDiscordIntegration", CurrentVersion = "1.0.34", MinimumRequiredVersion = "1.0.34" }; ConfigManager.Instance.Initialize(((BaseUnityPlugin)this).Config); DiscordIntegrationConfig.Initialize(((BaseUnityPlugin)this).Config); DiscordIntegrationConfig.BindToSync(configSync); Harmony.PatchAll(); DiscordSink.Register((IDiscordSink)(object)new DiscordSinkAdapter()); try { DiscordBotIdentity.ResolveUsername("FiresDiscordIntegration"); } catch (Exception ex2) { Debug.LogWarning((object)("[FiresDiscordIntegration] BotIdentity pre-warm failed: " + ex2.Message)); } try { DiscordMentionResolver.FetchRoles(); } catch (Exception ex3) { Debug.LogWarning((object)("[FiresDiscordIntegration] MentionResolver pre-warm failed: " + ex3.Message)); } WrapperBatExtractor.ExtractIfMissing(); try { FdiHelpContent.Register(); } catch (Exception ex4) { Log.LogWarning((object)("Help registration failed: " + ex4.Message)); } Log.LogInfo((object)"v1.0.34 loaded."); ((MonoBehaviour)this).StartCoroutine(EmitCompactBannerWhenZNetReady()); } private IEnumerator EmitCompactBannerWhenZNetReady() { while ((Object)(object)ZNetScene.instance == (Object)null || ZNetScene.instance.m_prefabs == null || ZNetScene.instance.m_prefabs.Count == 0) { yield return null; } yield return null; EmitDiscordLoadSummary(); try { FiresDiscordBanner.Print(); } catch (Exception ex) { ((BaseUnityPlugin)this).Logger.LogInfo((object)("FiresDiscordIntegration v1.0.34 loaded. (banner failed: " + ex.Message + ")")); } } private static void EmitDiscordLoadSummary() { //IL_000b: Unknown result type (might be due to invalid IL or missing references) //IL_0010: Unknown result type (might be due to invalid IL or missing references) try { bool heartbeatArmed = ServerHeartbeat.HeartbeatArmed; TaggedEmitter val = LoadSummary.For("FiresDiscordIntegration"); ((TaggedEmitter)(ref val)).EmitMiniBox("📡 DISCORD LINK", new string[5] { "bot listener: " + (DiscordBotListener.Running ? "on" : "off"), "crash detector: " + (ServerHeartbeat.CrashNotifierArmed ? "on" : "off"), heartbeatArmed ? $"heartbeat: {ServerHeartbeat.IntervalSeconds:0}s" : "heartbeat: off", ClientLogRelayIntegration.RelayActive ? "client log relay: on" : "client log relay: off", ChatRelaySummary(out var detail) }); Debug.Log((object)("[Discord] " + detail)); } catch (Exception ex) { FiresLogger.LogWarning("discord summary failed: " + ex.Message); } } private static string ChatRelaySummary(out string detail) { string text = "shout hooks: " + Hooked(typeof(Chat), "SendText", "client relay") + ", " + Hooked(typeof(ZRoutedRpc), "RouteRPC", "server route") + ", " + Hooked(typeof(ZRoutedRpc), "HandleRoutedRPC", "server handle"); if (!DiscordIntegrationCore.IsActive()) { detail = "chat relay: off (Discord disabled); " + text; return "chat relay: off"; } string text2 = ((!string.IsNullOrEmpty(DiscordIntegrationConfig.ChatWebhookUrl?.Value)) ? "ChatWebhookUrl" : ((!string.IsNullOrEmpty(DiscordIntegrationConfig.GeneralWebhookUrl?.Value)) ? "GeneralWebhookUrl" : null)); if (text2 == null) { detail = "chat relay: OFF - set ChatWebhookUrl or GeneralWebhookUrl; " + text; return "chat relay: OFF (no webhook)"; } List<string> list = new List<string>(); ConfigEntry<bool> notifyPlayerShout = DiscordIntegrationConfig.NotifyPlayerShout; if (notifyPlayerShout != null && notifyPlayerShout.Value) { list.Add("shout"); } ConfigEntry<bool> notifyPlayerSay = DiscordIntegrationConfig.NotifyPlayerSay; if (notifyPlayerSay != null && notifyPlayerSay.Value) { list.Add("say"); } ConfigEntry<bool> notifyPlayerWhisper = DiscordIntegrationConfig.NotifyPlayerWhisper; if (notifyPlayerWhisper != null && notifyPlayerWhisper.Value) { list.Add("whisper"); } detail = "chat relay: on via " + text2 + " (" + ((list.Count > 0) ? string.Join(", ", list) : "no chat types on") + "); " + text; return "chat relay: on"; } private static string Hooked(Type type, string method, string label) { MethodInfo methodInfo = AccessTools.Method(type, method, (Type[])null, (Type[])null); Patches val = ((methodInfo != null) ? Harmony.GetPatchInfo((MethodBase)methodInfo) : null); if (val == null || (!val.Prefixes.Any((Patch p) => p.owner == "com.Fire.FiresDiscordIntegration") && !val.Postfixes.Any((Patch p) => p.owner == "com.Fire.FiresDiscordIntegration"))) { return "NO " + label; } return label; } private void OnDestroy() { ClientLogRelayWorker.Drain(); if (configSync != null) { configSync = null; } Instance = null; } } public sealed class ConfigManager { private static ConfigManager _instance; public ConfigEntry<bool> configVerboseLogging; public static ConfigManager Instance => _instance ?? (_instance = new ConfigManager()); public ConfigFile Config { get; private set; } public void Initialize(ConfigFile config) { Config = config; configVerboseLogging = config.Bind<bool>("General", "VerboseLogging", false, "Emit additional debug logs for the Discord/log-relay subsystems. Off by default — most operators only want this on for troubleshooting."); ConfigHotReload.Watch(config); } } internal static class FiresDiscordBanner { private static bool s_compactPrinted; private static readonly Segment[][] s_compactLines = new Segment[13][] { (Segment[])(object)new Segment[1] { new Segment(" ╭──────────╮", ConsoleColor.Blue) }, (Segment[])(object)new Segment[3] { new Segment(" │ ", ConsoleColor.Blue), new Segment("_/", ConsoleColor.Cyan), new Segment(" │", ConsoleColor.Blue) }, (Segment[])(object)new Segment[3] { new Segment(" │ ", ConsoleColor.Blue), new Segment("/_", ConsoleColor.Cyan), new Segment(" │", ConsoleColor.Blue) }, (Segment[])(object)new Segment[3] { new Segment(" │ ", ConsoleColor.Blue), new Segment("/", ConsoleColor.Cyan), new Segment(" │", ConsoleColor.Blue) }, (Segment[])(object)new Segment[1] { new Segment(" ╰──╮ ╭────╯", ConsoleColor.Blue) }, (Segment[])(object)new Segment[1] { new Segment(" ╰╯", ConsoleColor.DarkBlue) }, (Segment[])(object)new Segment[1] { new Segment(" ╔══════════════════════════╗", ConsoleColor.DarkGray) }, (Segment[])(object)new Segment[5] { new Segment(" ║ ", ConsoleColor.DarkGray), new Segment("[", ConsoleColor.Gray), new Segment("██████████████████", ConsoleColor.Green), new Segment("]", ConsoleColor.Gray), new Segment(" ║", ConsoleColor.DarkGray) }, (Segment[])(object)new Segment[3] { new Segment(" ║ ", ConsoleColor.DarkGray), new Segment("\ud83d\udce1 RELAY LINKED", ConsoleColor.Cyan), new Segment(" ║", ConsoleColor.DarkGray) }, (Segment[])(object)new Segment[1] { new Segment(" ╚══════════════════════════╝", ConsoleColor.DarkGray) }, (Segment[])(object)new Segment[1] { new Segment(" FIRES DISCORD INTEGRATION", ConsoleColor.Cyan) }, (Segment[])(object)new Segment[0], (Segment[])(object)new Segment[1] { new Segment(" Fires Discord Integration Loaded", ConsoleColor.Magenta) } }; private static readonly (string text, ConsoleColor color)[] s_bigLines = new(string, ConsoleColor)[70] { ("", ConsoleColor.Gray), ("%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%", ConsoleColor.Blue), ("%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@%%%%%%%%%", ConsoleColor.DarkBlue), ("%%%%%%%%%%@%%%%%%%%%%%%%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@%%%%%@%%%%%%%%%%%%%%%%%%%%", ConsoleColor.Blue), ("%%%%%%%%%%%%%%%%%%%%%%%%%%%@%@%%%%%%%%%%%=----::::::::----=%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@%%%%%%@@%", ConsoleColor.DarkBlue), ("%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%=--::::::::::::::::::::--=%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%", ConsoleColor.Blue), ("%%%%%%%%%%%@%%%%%%%%%%%%%%%%%%%%%%%=-::::::::::::::::::::::::::--+%%%%%%%%%%%%%%%@%%%%%%%%%%%%%%%%%%", ConsoleColor.DarkBlue), ("%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%-::::::::::::::::::::::::::::::::-=%%%%%%%@%%@%%%%%%%%%%%%%%%%%%%%%", ConsoleColor.Blue), ("%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%=-::::::::::::::::::-::::::::::::::::-%%%%%%%%%%%%%%%%%%%%%%%%%%%@%%%", ConsoleColor.DarkBlue), ("%%%%%%%%%%%%%@%%%%%%%%%%%%%%%%-:::::::::::::::::::=-:::::::::::::::::-=%%%%%%%%%%%%%%%%%%%%%%%%%%%%%", ConsoleColor.Blue), ("%%%%%%%@%%%%%%%%%%%%%%%%@%%%%-:::::::::::::::::::-%-:::::::::::::::::::=%%%%%%%%%%%%%%%%%%%%%%%%%%%%", ConsoleColor.DarkBlue), ("%%%%%%%%%%%%%%%%%%%%%%%%%%%%-:::::::::::::::::::-%@:::::::::::::::::::::=%%%%%%%@%%%%%%%@%%%%%%%%%%%", ConsoleColor.Blue), ("%%%%%%%%%%%%%%%%%%%%%%%%%%%-:::::::::::::::::::-%%=:::::::::::::::::::::-%%%%%%%%%%%%%%%%%%%%%%%%%%%", ConsoleColor.DarkBlue), ("%%%%%%@%%@%%%%%%%%%%%%%%%%+::::::::::::::::::::=%%-::::::::::::::::::::::=%%%%%%%%%%%%%%%%%%%%%%%%%@", ConsoleColor.Blue), ("%%%%%%%%%@%%%%%%%%%%%%%%%%-:::::::::::::::::::=%%%-::::::::::::::::::::::-%%%%%%%%%%%%@%%%%@%%%%%%%%", ConsoleColor.DarkBlue), ("%%%%%%%%%%%%%%%%%%%%%%%%%%-::::::::::::::::::-%%%%*****-::::::::::::::::::%%%%%%%%%%%%%%%%%%%%%%%%%%", ConsoleColor.Blue), ("%%%%%%%%%%%%%%%@%%%%%%%%%%::::::::::::::::::-%%%%%%%%%-:::::::::::::::::::*%%%%%%%%%%%%%%%%%%@%%%%%%", ConsoleColor.DarkBlue), ("%%%%%%%%%%%%%%%%%%%%%%%%%%:::::::::::::::::-@@%%%%%%%-::::::::::::::::::::*%%%%%%%%%%%%%%%%%@%%%%%%%", ConsoleColor.Blue), ("%%%%%%%%%%%@%%%%%@%%%%%%%%-::::::::::::::::::::::#%%-:::::::::::::::::::::%%%%%%%%%%%%%%%%%%%%%%%%%%", ConsoleColor.DarkBlue), ("%%%%%%%%%%%%%%@%%%%%%%%%%@-:::::::::::::::::::::-%%-:::::::::::::::::::::-%%%%%%%%%%%%%%%%%%%%%%%%%%", ConsoleColor.Blue), ("%%%%%%%%%%%%%%%%%%%%%%%%%%=:::::::::::::::::::::%%-::::::::::::::::::::::-%%%%%%%%%%%%%%%%%%%%%%%%%%", ConsoleColor.DarkBlue), ("%%%%%%%%%%%%%%%%%%%%%%%%%%%-:::::::::::::::::::=%-::::::::::::::::::::::-%%%%%%%%%%%%%%%%%%%%%%%%%%%", ConsoleColor.Blue), ("%%%%%%%%%%%%%%%%%%%%%%%%%%%%-:::::::::::::::::-%-:::::::::::::::::::::::=%%@%%%%%%%%%%%@%%%%%%%%%%%%", ConsoleColor.DarkBlue), ("%@%%%%%%%%%%%%%%%%%%%%%%%@%%+-::::::::::::::::=-:::::::::::::::::::::::=%%%%%%%%%%%%@%%%%%%%%%%%%%%%", ConsoleColor.Blue), ("%%%%%%%%%%%@%%%%%%@%%%%%%@%%%#-::::::::::::::--::::::::::::::::::::::-=%%%%%%%%%%%%%%%%%%%%%%%%%%%%%", ConsoleColor.DarkBlue), ("%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%-:::::::::::::-::::::::::::::::::::::-%%%%%@%%%%%%%%%@%%%%%%%%%%%%%%%", ConsoleColor.Blue), ("%%%%%%%%%%%%%%%%%%%%%%%%%%%@%%%%+-::::::::::::::::::::::::::::::::-=%%%@%%%%%%%%%%%%%%%%%%%%%%%%@%%%", ConsoleColor.DarkBlue), ("%%%%%%%%%%%%%%%%%%%@%%%%%%%%%%%%%%=-::::::::::::::::::::::::::::-=%%%%%%%%%%%%@%%%%%%%%%%%%%%%%%@%%%", ConsoleColor.Blue), ("%%%%%%%%%%%%%%%%@%%%%%%%%%%%%%%%%%%%%=-::::::::::::::::::::::-=%%%%%%%%@%%%%%%%%%%%%%%%%@%%%%%%%%%%%", ConsoleColor.DarkBlue), ("%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%-::::::::::::::::---=%%%@%%%%%%%%%%%%+++#%%%@%%%%%%%%%%%%%%%%", ConsoleColor.Blue), ("%%%%%%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%@%%=::::::::::-=*%%%%%%%%%%%%%%%%%%%%%%%%+=+#%%%%%%@%%%%%%%%%%%%%", ConsoleColor.DarkBlue), ("%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%-:::::::::=%%%%%%%%%%%%%%%%%%%%%=-=#%%%#%%%%%%%@%%%%%%%%%%%%%%", ConsoleColor.Blue), ("%%%%%%%%%%%%%%%@%%%%%%%%%%%%%@%%%%%%%@::::::::-%%%%%%%%%%%%%%%%%%%%%%%=--*%%%%%%%%%%%%%%%%%%%%%%%%%%", ConsoleColor.DarkCyan), ("%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%-::::::-=%%%%@%%%%%%%%%%%%%-::#%%%*%%%%%@%%%%%%%%%%%%%%%%%%%%%%", ConsoleColor.Cyan), ("%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%:::::-=%%%%%%%%%%%%%%%%%%%%:::*%%%%%%%%%%%%%%%%%%%@%%%%@%%%%%%%", ConsoleColor.DarkCyan), ("%%%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%=::::-%%%%%%%%%%@%%%%%%%%%%%%*%%%%%%%%%%%%%%%%%%%%%%%%@%%%%%%%%%", ConsoleColor.Cyan), ("%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%-::-=%%%%%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@", ConsoleColor.DarkCyan), ("%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%=:-=%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%", ConsoleColor.Cyan), ("%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%--%%%%%%%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@", ConsoleColor.DarkCyan), ("%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@%%%%@%%%%%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@%%%%%%%%%", ConsoleColor.Cyan), ("%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%", ConsoleColor.DarkCyan), ("%%%%%%%%%%%%%%%@@%%%%%%%%%%%%%%%%@%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@%%%%%%%%%%%%%%%%%%%%%%%%%@%%", ConsoleColor.Cyan), ("%%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@@%%%%%%%%%%%@%%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%@%%%%", ConsoleColor.White), ("%%%%%%%%%%%%%%%%%%%%%%%%%%@%%%%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%", ConsoleColor.Blue), ("%%%%%%%%%%%%-::::::::-%%-::::%%-:::.:::-%%%-::.:::-%%%@-::.:::-%%-::::::::-%%-::::::::-%%%%%%%%%%%%%", ConsoleColor.White), ("%%%%%%%%%%%%:.........-%:....%-....:...:%%:...::...:%%:...:....:%-.........-%:.........-%%%%%%%%%%%%", ConsoleColor.Blue), ("%%%%%%%%%%%%:....%:...:%:....%:...:@....%+....--....%+....-:...:%-....#:...:%:...:%....:%%%%%%%%%%%%", ConsoleColor.White), ("%%%@%%%%%%%%:....%:...:%:....%-....::-%%%+....--....%-....=:...:%-....:....-%:...:%....:%%%%@%%%%%%%", ConsoleColor.Blue), ("%%%%%%%%%%%%:....%:...:%:....%%-:.....:-%+....-%%%%@%-....=:....%-........:%%:...:%....:%%%%@%%%%%%%", ConsoleColor.White), ("%@%%%%%%%%%%:....%:...:%:....%----::....-+....--::::%-....=:...:%-....#:...-%:...:%....:%%%%%%%%%%%%", ConsoleColor.Blue), ("%%%%%%%%%%%%:....%:...:%:....%:...:%....-*....--....%=....-:...:%-....#:...:%:...:%....:%@%%%%%%%%%%", ConsoleColor.White), ("%%%%%%%%%%%%:.........:%:....%-....:....-%:...::...:%%:...:....:%-....#:...:%:.........-%%%%%%%%%%%%", ConsoleColor.Blue), ("%%%%%%%%%%%%-::::::::-@%-::::%%-::...::-%%=:::..::-%%%*-::..::-%%-::::#::::-%-::::::::-%%%%%%%%%%%%%", ConsoleColor.White), ("%%@%%%%%%%%%%%%%%%%%%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@%%%%%%%%%%%%%%%%%%%%%%", ConsoleColor.Blue), ("%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@%%%%%%%%%%%%%%%%%%%%%%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@%%%%%%%%%%%%%%%", ConsoleColor.White), ("%%%%%%%%%%%%%%%%%%%%@%%%%%%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@%%%%%", ConsoleColor.Blue), ("%%%%%%%%%%%%%%%%%%%%%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%", ConsoleColor.White), ("%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@%%%%%%%%%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%", ConsoleColor.Blue), ("%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%", ConsoleColor.White), ("%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@%%%%%%%", ConsoleColor.Blue), ("%%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%", ConsoleColor.White), ("%%%%%%%%%%%%%%%%%%@%%%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@%%%%@%%%%%%@%%%%%%%%%%%%", ConsoleColor.Blue), ("%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@%%%%%%%%%%%%@%%%%%%%%%%%%%%%%@%%%%%%%%%@%%%%%%%%%%%%%@%%%%%%%%%%%", ConsoleColor.White), ("@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@%%%%%%%%%%@%%%%%%%%%%%%%%%%%%%%", ConsoleColor.Blue), ("%%%%%%%%%%%%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%", ConsoleColor.DarkGray), ("%%%%%%%%%%%%@%%%%%%%%%%%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%", ConsoleColor.DarkGray), ("%%%%%%%%%%%@%%%%%%%%%%%@%%%%%%%%%%%%%%%%%%%@%%%%%%%%%%%%%%%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%", ConsoleColor.DarkGray), ("%%%%@%%%%%%%@%%%%%%%%%%%%%%%%%%%%@%%%%%%%%%%%%%%%%%%%%%%%@%%%%%%%%%@%%%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%", ConsoleColor.DarkGray), ("%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@%%%%%%%%%%%%", ConsoleColor.DarkGray), ("", ConsoleColor.Gray) }; public static void PrintBig() { try { EmitHeader("Fires Discord Integration Loading..."); Banner.Print(ToSegments(s_bigLines)); } catch (Exception ex) { Debug.LogWarning((object)("[FiresDiscordIntegration] Big banner failed: " + ex.Message)); } } public static void Print() { if (s_compactPrinted) { return; } s_compactPrinted = true; try { EmitHeader("Fires Discord Integration Loaded."); Banner.Print(s_compactLines); } catch (Exception ex) { Debug.LogWarning((object)("[FiresDiscordIntegration] Compact banner failed: " + ex.Message)); } } private static void EmitHeader(string text) { if (FiresDiscordIntegrationPlugin.Log != null) { FiresDiscordIntegrationPlugin.Log.LogInfo((object)text); } else { Debug.Log((object)("[FiresDiscordIntegration] " + text)); } } private static Segment[][] ToSegments((string text, ConsoleColor color)[] lines) { //IL_002f: Unknown result type (might be due to invalid IL or missing references) //IL_0034: Unknown result type (might be due to invalid IL or missing references) Segment[][] array = new Segment[lines.Length][]; for (int i = 0; i < lines.Length; i++) { array[i] = (Segment[])(object)new Segment[1] { new Segment(lines[i].text, lines[i].color) }; } return array; } } public static class FiresLogger { private static readonly FiresLog Log = new FiresLog("FiresDiscordIntegration", (Func<bool>)(() => ConfigManager.Instance?.configVerboseLogging?.Value == true)); public static bool VerboseEnabled => Log.VerboseEnabled; public static void LogInfo(string message) { Log.Info(message); } public static void LogVerbose(string message) { Log.Verbose(message); } public static void LogDebug(string message) { Log.Debug(message); } public static void LogWarning(string message) { Log.Warning(message); } public static void LogError(string message) { Log.Error(message); } } } namespace FiresDiscordIntegration.ClientLogRelay { [HarmonyPatch] public static class ClientLogRelayIntegration { private const string MOD_ID = "FiresDiscordIntegration"; private const string PLUGIN_BRAND = "FiresDiscordIntegration"; private const string FallbackWebhookDisplayName = "Server Relay"; private const string RPC_SUBMIT = "VAG_SubmitClientLog"; private const string RPC_UPDATE = "VAG_UpdateClientLog"; private const float CLIENT_PUSH_DELAY_SECONDS = 15f; private const float CLIENT_REPUSH_INTERVAL_SECONDS = 900f; private const float GLOBAL_MIN_POST_INTERVAL_SECONDS = 10f; private const int CHUNK_BYTES = 65536; private static readonly HashSet<long> _postedPeers = new HashSet<long>(); private static readonly object _debounceLock = new object(); private static DateTime _lastPostUtc = DateTime.MinValue; private static bool _clientPushedThisSession; private static bool _initialized; private static bool _isServerSide; private static readonly Dictionary<long, string> _peerToPlatformId = new Dictionary<long, string>(); private static readonly Dictionary<long, string> _peerToPlayerName = new Dictionary<long, string>(); private static bool MasterEnabled => DiscordIntegrationConfig.NotifyClientLoginArtifacts?.Value ?? true; private static string PrimaryWebhookUrl => DiscordIntegrationConfig.ResolveWebhookUrl(WebhookCategory.ClientLog) ?? string.Empty; private static string SecondaryWebhookUrl => string.Empty; private static bool AutoSendFullLog => DiscordIntegrationConfig.AttachFullClientLogOnLogin?.Value ?? false; private static bool AttachModListOnLogin => DiscordIntegrationConfig.AttachModListOnLogin?.Value ?? true; private static bool AttachErrorsWarningsOnLogin => DiscordIntegrationConfig.AttachErrorsWarningsOnLogin?.Value ?? true; private static bool OnlyNotifyLoginIfErrorsOrWarnings => DiscordIntegrationConfig.OnlyNotifyLoginIfErrorsOrWarnings?.Value ?? false; private static bool LogRequestReactionEnabled => DiscordIntegrationConfig.EnableLogRequestReaction?.Value ?? true; private static string BotTokenValue => BotTokenFile.Token; private static string ServerManagerWebhookUrl => DiscordIntegrationConfig.ServerManagerWebhookUrl?.Value ?? string.Empty; private static string ServerManagerChannelIdValue { get { string text = DiscordIntegrationConfig.ServerManagerChannelId?.Value; if (!string.IsNullOrEmpty(text)) { return text; } return DiscordIntegrationConfig.StatusChannelId?.Value ?? string.Empty; } } internal static bool RelayActive { get { if (_initialized) { if (_isServerSide) { return MasterEnabled; } return true; } return false; } } public static string ClientLogsRoot => ClientLogRelayPaths.GetDefaultClientLogsDir("FiresDiscordIntegration"); private static string ResolveWebhookName() { string text = DiscordIntegrationConfig.WebhookDisplayName?.Value; return DiscordBotIdentity.ResolveUsername((!string.IsNullOrEmpty(text) && text != "Valheim Server") ? text : "Server Relay"); } private static string ResolveAvatarUrl() { string text = DiscordIntegrationConfig.WebhookAvatarUrl?.Value; if (!string.IsNullOrEmpty(text)) { return text; } return DiscordBotIdentity.ResolveAvatarUrl(null); } public static void InitServer() { if (!_initialized) { _initialized = true; _isServerSide = true; ClientLogRelayWorker.Prepare(); if (!MasterEnabled) { FiresLogger.LogVerbose("[ClientLogRelay] Disabled by config (Discord.ClientLogs.NotifyClientLoginArtifacts=false) - skipping initialisation."); return; } TryRun("RegisterDiskConsumer", RegisterDiskConsumer); TryRun("RegisterDiscordConsumer", RegisterDiscordConsumer); TryRun("StartReactionPoller", StartReactionPoller); TryRun("StartServerStatusPoster", StartServerStatusPoster); FiresLogger.LogVerbose("[ClientLogRelay] Server-side integration initialised."); } } public static void InitClient() { if (!_initialized) { _initialized = true; _isServerSide = false; FiresLogger.LogVerbose("[ClientLogRelay] Client-side integration initialised (will push log on connect)."); } } private static void TryRun(string label, Action action) { try { action(); } catch (Exception ex) { Debug.LogWarning((object)("[ClientLogRelay] " + label + " failed: " + ex.GetType().Name + ": " + ex.Message)); } } private static void RegisterDiskConsumer() { ClientLogRelay.RegisterConsumer((IClientLogConsumer)(object)new DiskConsumer("FiresDiscordIntegration.ClientLogRelay.Disk", () => ClientLogRelayPaths.GetDefaultClientLogsDir("FiresDiscordIntegration"), () => true)); FiresLogger.LogVerbose("[ClientLogRelay] Disk consumer will persist artifacts under: " + ClientLogsRoot); } private static void RegisterDiscordConsumer() { ClientLogRelay.RegisterConsumer((IClientLogConsumer)(object)new DiscordWebhookConsumer("FiresDiscordIntegration.ClientLogRelay.Discord.Primary") { EnabledGate = () => MasterEnabled && !string.IsNullOrEmpty(PrimaryWebhookUrl), WebhookUrl = () => PrimaryWebhookUrl, WebhookName = ResolveWebhookName, AvatarUrl = ResolveAvatarUrl, BrandLabel = () => "FiresDiscordIntegration", AttachFullLog = () => AutoSendFullLog, AttachModList = () => AttachModListOnLogin, AttachErrorsWarnings = () => AttachErrorsWarningsOnLogin, OnlyIfErrorsOrWarnings = () => OnlyNotifyLoginIfErrorsOrWarnings, EnableLogRequestReaction = () => LogRequestReactionEnabled, BotToken = () => BotTokenValue, ServerName = () => (!((Object)(object)ZNet.instance != (Object)null)) ? "Unknown" : ZNet.instance.GetWorldName() }); ClientLogRelay.RegisterConsumer((IClientLogConsumer)(object)new DiscordWebhookConsumer("FiresDiscordIntegration.ClientLogRelay.Discord.Secondary") { EnabledGate = () => MasterEnabled && !string.IsNullOrEmpty(SecondaryWebhookUrl), WebhookUrl = () => SecondaryWebhookUrl, WebhookName = ResolveWebhookName, AvatarUrl = ResolveAvatarUrl, BrandLabel = () => "FiresDiscordIntegration", AttachFullLog = () => AutoSendFullLog, AttachModList = () => AttachModListOnLogin, AttachErrorsWarnings = () => AttachErrorsWarningsOnLogin, OnlyIfErrorsOrWarnings = () => OnlyNotifyLoginIfErrorsOrWarnings, EnableLogRequestReaction = () => false, BotToken = () => (string)null, ServerName = () => (!((Object)(object)ZNet.instance != (Object)null)) ? "Unknown" : ZNet.instance.GetWorldName() }); } private static void StartReactionPoller() { if (string.IsNullOrEmpty(BotTokenValue)) { FiresLogger.LogVerbose("[ClientLogRelay] No bot token configured — reaction poller disabled."); return; } if (string.IsNullOrEmpty(PrimaryWebhookUrl)) { FiresLogger.LogVerbose("[ClientLogRelay] No primary webhook URL configured — reaction poller disabled."); return; } ReactionPoller.Create(() => BotTokenValue, () => PrimaryWebhookUrl, () => ClientLogsRoot, ResolveWebhookName); FiresLogger.LogVerbose("[ClientLogRelay] Reaction poller started — polling every 60s for log requests (90s warmup per fresh snapshot to dodge Discord's reactions rate-limit bucket)."); } private static void StartServerStatusPoster() { string botTokenValue = BotTokenValue; string serverManagerChannelIdValue = ServerManagerChannelIdValue; if (string.IsNullOrEmpty(botTokenValue)) { FiresLogger.LogVerbose("[ClientLogRelay] No bot token configured (Discord.BotListener.BotToken) - Server Manager panel disabled."); } else if (string.IsNullOrEmpty(serverManagerChannelIdValue)) { FiresLogger.LogVerbose("[ClientLogRelay] No channel ID configured (Discord.ServerManager.ServerManagerChannelId or Discord.BotListener.StatusChannelId) - Server Manager panel disabled."); } else { FiresLogger.LogVerbose("[ClientLogRelay] Server Manager panel configured - will start when ZNet is ready."); } } [HarmonyPatch(typeof(ZNet), "Awake")] [HarmonyPostfix] public static void ZNet_Awake_Postfix(ZNet __instance) { try { if (!_initialized) { if ((Object)(object)__instance != (Object)null && __instance.IsServer()) { InitServer(); } else { InitClient(); } } if (ZRoutedRpc.instance == null) { Debug.LogWarning((object)"[ClientLogRelay] ZNet.Awake postfix fired but ZRoutedRpc.instance is null - RPC registration skipped."); } else if (_isServerSide) { ZRoutedRpc.instance.Register<ZPackage>("VAG_SubmitClientLog", (Action<long, ZPackage>)OnServerReceiveLog); ZRoutedRpc.instance.Register<ZPackage>("VAG_UpdateClientLog", (Action<long, ZPackage>)OnServerReceiveLogUpdate); FiresLogger.LogVerbose("[ClientLogRelay] Registered server-side handlers for 'VAG_SubmitClientLog' + 'VAG_UpdateClientLog'."); ((MonoBehaviour)ZNet.instance).StartCoroutine(PeriodicTransferCleanup()); string botTokenValue = BotTokenValue; string serverManagerWebhookUrl = ServerManagerWebhookUrl; string serverManagerChannelIdValue = ServerManagerChannelIdValue; if (!string.IsNullOrEmpty(botTokenValue) && !string.IsNullOrEmpty(serverManagerChannelIdValue)) { ServerHeartbeat.OnServerStart(botTokenValue, serverManagerWebhookUrl, serverManagerChannelIdValue, "FiresDiscordIntegration Relay", "FiresDiscordIntegration"); FiresLogger.LogVerbose("[ClientLogRelay] Server heartbeat + control panel started."); } } else { _clientPushedThisSession = false; FiresLogger.LogVerbose("[ClientLogRelay] Client-side ZNet awake — push-on-connect armed."); } } catch (Exception ex) { Debug.LogWarning((object)("[ClientLogRelay] ZNet.Awake postfix threw: " + ex.GetType().Name + ": " + ex.Message)); } } [HarmonyPatch(typeof(ZNet), "RPC_PeerInfo")] [HarmonyPostfix] public static void ZNet_RPC_PeerInfo_Postfix(ZNet __instance, ZRpc rpc) { try { if (!_isServerSide && !((Object)(object)__instance == (Object)null) && rpc != null) { if (_clientPushedThisSession) { FiresLogger.LogVerbose("[ClientLogRelay] PeerInfo postfix fired but client already pushed this session — skipping."); return; } _clientPushedThisSession = true; FiresLogger.LogVerbose($"[ClientLogRelay] PeerInfo handshake complete — scheduling log push in {15f:F0}s."); ((MonoBehaviour)__instance).StartCoroutine(DelayedClientPush()); ((MonoBehaviour)__instance).StartCoroutine(PeriodicClientRepush()); } } catch (Exception ex) { Debug.LogWarning((object)("[ClientLogRelay] ZNet.RPC_PeerInfo postfix threw: " + ex.GetType().Name + ": " + ex.Message)); } } private static bool ServerKeepsClientLogs() { if (MasterEnabled) { return true; } FiresLogger.LogVerbose("[ClientLogRelay] The server's client-log relay is off (Discord.ClientLogs.NotifyClientLoginArtifacts); not sending this client's log."); return false; } private static IEnumerator DelayedClientPush() { FiresLogger.LogVerbose("[ClientLogRelay][DIAG] DelayedClientPush ENTER (waiting for PlayerSpawnGate to open)"); bool playerReady = false; int attempt = 0; while (!playerReady && attempt < 20) { attempt++; bool thisAttemptReady = false; PlayerSpawnGate.RunWhenLocalReady(30f, (Action<Player>)delegate { thisAttemptReady = true; }); float attemptDeadlineAt = Time.realtimeSinceStartup + 30f + 5f; while (!thisAttemptReady && Time.realtimeSinceStartup < attemptDeadlineAt) { yield return null; } if (thisAttemptReady) { playerReady = true; FiresLogger.LogVerbose($"[ClientLogRelay] Player ready on attempt {attempt}; proceeding with log push."); break; } FiresLogger.LogVerbose($"[ClientLogRelay] Player still not ready after {30f:F0}s " + $"(attempt {attempt}/{20}) — retrying."); } if (!playerReady) { Debug.LogWarning((object)($"[ClientLogRelay] Player never became ready after {20} attempts " + $"({10f:F0} min total) — " + "giving up on log push for this session.")); yield break; } FiresLogger.LogVerbose("[ClientLogRelay][DIAG] DelayedClientPush playerReady=true, entering 2s post-ready grace"); yield return (object)new WaitForSeconds(2f); FiresLogger.LogVerbose("[ClientLogRelay][DIAG] DelayedClientPush 2s grace elapsed, about to call PushLogToServerCoroutine"); if ((Object)(object)ZNet.instance == (Object)null) { FiresLogger.LogVerbose("[ClientLogRelay] ZNet.instance gone before push — cancelling."); yield break; } if (ZRoutedRpc.instance == null) { Debug.LogWarning((object)"[ClientLogRelay] ZRoutedRpc.instance null at push time — cancelling."); yield break; } long serverPeerID = ZRoutedRpc.instance.GetServerPeerID(); if (serverPeerID == 0L) { Debug.LogWarning((object)"[ClientLogRelay] ServerPeerID is 0 at push time — connection no longer valid; cancelling."); } else { yield return ((MonoBehaviour)ZNet.instance).StartCoroutine(PushLogToServerCoroutine(serverPeerID)); } } private static IEnumerator PushLogToServerCoroutine(long serverId) { if (!ServerKeepsClientLogs()) { yield break; } FiresLogger.LogVerbose($"[ClientLogRelay] PushLogToServer entered (serverId={serverId}) - reading local LogOutput.log..."); byte[] logBytes = null; Dictionary<string, string> modList = null; try { ClientLogCollector.TryCollect(ref logBytes, ref modList); if (modList == null) { modList = new Dictionary<string, string>(0, StringComparer.OrdinalIgnoreCase); } byte[] array = logBytes; FiresLogger.LogVerbose($"[ClientLogRelay] PushLogToServer read complete - logBytes={((array != null) ? array.Length : 0)}B, mods={modList.Count}."); } catch (Exception ex) { Debug.LogWarning((object)("[ClientLogRelay] TryCollect failed: " + ex.Message)); yield break; } yield return null; string text = string.Empty; try { text = GetOwnSteamId() ?? string.Empty; } catch (Exception ex2) { FiresLogger.LogVerbose("[ClientLogRelay] SteamID unavailable (" + ex2.GetType().Name + ": " + ex2.Message + "); pushing without it."); } logBytes = logBytes ?? new byte[0]; int logSize = logBytes.Length; int totalChunks = ((logSize == 0) ? 1 : ((logSize + 65536 - 1) / 65536)); FiresLogger.LogVerbose($"[ClientLogRelay] Pushing log to server: {logSize}B in {totalChunks} chunk(s) + metadata, mods={modList.Count}"); ZPackage val = new ZPackage(); val.Write(-1); val.Write(totalChunks); val.Write(modList.Count); foreach (KeyValuePair<string, string> item in modList) { val.Write(item.Key ?? string.Empty); val.Write(item.Value ?? string.Empty); } val.Write(text ?? string.Empty); byte[] array2 = val.GetArray(); if (array2 != null && array2.Length > 420000) { Debug.LogWarning((object)$"[ClientLogRelay] Metadata ZPackage is {array2.Length}B - very large mod list!"); } if (ZRoutedRpc.instance == null) { Debug.LogWarning((object)"[ClientLogRelay] ZRoutedRpc gone before metadata send - aborting push."); yield break; } ZRoutedRpc.instance.InvokeRoutedRPC(serverId, "VAG_SubmitClientLog", new object[1] { val }); FiresLogger.LogVerbose($"[ClientLogRelay] -> Sent metadata ({((array2 != null) ? array2.Length : 0)}B total, {modList.Count} mods)"); yield return null; for (int chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { if (ZRoutedRpc.instance == null) { Debug.LogWarning((object)$"[ClientLogRelay] ZRoutedRpc gone at chunk {chunkIndex + 1}/{totalChunks} - aborting push."); yield break; } ZPackage val2 = new ZPackage(); val2.Write(chunkIndex); val2.Write(totalChunks); int num = chunkIndex * 65536; int num2 = Math.Min(65536, logSize - num); byte[] array3 = new byte[num2]; if (num2 > 0) { Array.Copy(logBytes, num, array3, 0, num2); } val2.Write(array3); byte[] array4 = val2.GetArray(); if (array4 != null && array4.Length > 420000) { Debug.LogWarning((object)$"[ClientLogRelay] CRITICAL: Chunk {chunkIndex + 1} ZPackage is {array4.Length}B!"); } ZRoutedRpc.instance.InvokeRoutedRPC(serverId, "VAG_SubmitClientLog", new object[1] { val2 }); if (totalChunks > 1) { FiresLogger.LogVerbose($"[ClientLogRelay] -> Sent chunk {chunkIndex + 1}/{totalChunks} ({num2}B data, {((array4 != null) ? array4.Length : 0)}B total)"); } yield return null; } Debug.Log((object)$"[ClientLogRelay] Push complete: {logSize}B in {totalChunks} chunk(s)"); } private static IEnumerator PeriodicClientRepush() { while (true) { yield return (object)new WaitForSeconds(900f); if ((Object)(object)ZNet.instance == (Object)null || ZRoutedRpc.instance == null) { FiresLogger.LogVerbose("[ClientLogRelay] Repush skipped: ZNet or ZRoutedRpc is null (will retry next interval)"); } else if (ServerKeepsClientLogs()) { long serverPeerID = ZRoutedRpc.instance.GetServerPeerID(); if (serverPeerID == 0L) { FiresLogger.LogVerbose("[ClientLogRelay] Repush skipped: not connected to server (will retry next interval)"); } else { yield return ((MonoBehaviour)ZNet.instance).StartCoroutine(RepushLogToServerCoroutine(serverPeerID)); } } } } private static IEnumerator PeriodicTransferCleanup() { while (true) { yield return (object)new WaitForSeconds(30f); ClientLogChunkedTransfer.CleanupTimedOutTransfers(); } } private static IEnumerator RepushLogToServerCoroutine(long serverId) { byte[] logBytes = null; try { Dictionary<string, string> dictionary = default(Dictionary<string, string>); ClientLogCollector.TryCollect(ref logBytes, ref dictionary); } catch (Exception ex) { Debug.LogWarning((object)("[ClientLogRelay] Repush TryCollect failed: " + ex.Message)); yield break; } yield return null; string steamId = string.Empty; try { steamId = GetOwnSteamId() ?? string.Empty; } catch { } logBytes = logBytes ?? new byte[0]; int logSize = logBytes.Length; if (logSize <= 65536) { if (ZRoutedRpc.instance != null) { ZPackage val = new ZPackage(); val.Write(logBytes); val.Write(steamId); ZRoutedRpc.instance.InvokeRoutedRPC(serverId, "VAG_UpdateClientLog", new object[1] { val }); FiresLogger.LogVerbose(string.Format("[ClientLogRelay] Re-pushed '{0}' to server: {1}B (single message)", "VAG_UpdateClientLog", logSize)); } yield break; } int totalChunks = (logSize + 65536 - 1) / 65536; FiresLogger.LogVerbose($"[ClientLogRelay] Re-pushing large log to server: {logSize}B in {totalChunks} chunk(s)"); for (int chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { if (ZRoutedRpc.instance == null) { yield break; } ZPackage val2 = new ZPackage(); val2.Write(chunkIndex); val2.Write(totalChunks); int num = chunkIndex * 65536; int num2 = Math.Min(65536, logSize - num); byte[] array = new byte[num2]; if (num2 > 0) { Array.Copy(logBytes, num, array, 0, num2); } val2.Write(array); val2.Write(steamId); ZRoutedRpc.instance.InvokeRoutedRPC(serverId, "VAG_UpdateClientLog", new object[1] { val2 }); if (totalChunks > 1) { FiresLogger.LogVerbose($"[ClientLogRelay] -> Re-pushed chunk {chunkIndex + 1}/{totalChunks} ({num2}B)"); } yield return null; } FiresLogger.LogVerbose($"[ClientLogRelay] Re-push complete: {logSize}B in {totalChunks} chunk(s)"); } public static void ResetDebounce() { lock (_debounceLock) { int count = _postedPeers.Count; _postedPeers.Clear(); _lastPostUtc = DateTime.MinValue; Debug.Log((object)$"[ClientLogRelay] Debounce reset (cleared {count} posted peers)."); } } private static void OnServerReceiveLog(long sender, ZPackage pkg) { //IL_0306: Unknown result type (might be due to invalid IL or missing references) //IL_0310: Expected O, but got Unknown try { if (!MasterEnabled) { return; } if (pkg == null) { Debug.LogWarning((object)$"[ClientLogRelay] Submit from peer {sender} had a null payload."); return; } int num = pkg.ReadInt(); int num2 = pkg.ReadInt(); if (num == -1) { int num3 = pkg.ReadInt(); if (num3 < 0) { num3 = 0; } Dictionary<string, string> dictionary = new Dictionary<string, string>(num3, StringComparer.OrdinalIgnoreCase); for (int i = 0; i < num3; i++) { string text = pkg.ReadString(); string text2 = pkg.ReadString(); if (!string.IsNullOrEmpty(text)) { dictionary[text] = text2 ?? string.Empty; } } string text3 = null; try { text3 = pkg.ReadString(); } catch { } ClientLogChunkedTransfer.StoreMetadata(sender, num2, dictionary, text3); FiresLogger.LogVerbose($"[ClientLogRelay] ← Received metadata from peer {sender}: {dictionary.Count} mods, expecting {num2} chunk(s)"); return; } byte[] array = pkg.ReadByteArray(); FiresLogger.LogVerbose($"[ClientLogRelay] ← Received chunk {num + 1}/{num2} from peer {sender} ({array.Length}B)"); TransferResult val = ClientLogChunkedTransfer.ReceiveChunk(sender, num, num2, array, (Dictionary<string, string>)null, (string)null); if (val == null) { return; } byte[] fullLog = val.LogBytes; Dictionary<string, string> dictionary2 = val.ModList; string steamId = val.SteamId; if (dictionary2 == null) { dictionary2 = new Dictionary<string, string>(0, StringComparer.OrdinalIgnoreCase); } FiresLogger.LogVerbose($"[ClientLogRelay] ← Complete log received from peer {sender}: {fullLog.Length}B, {dictionary2.Count} mods"); string playerName = GetPeerPlayerName(sender); string platformId = ((!string.IsNullOrEmpty(steamId)) ? steamId : sender.ToString()); lock (_debounceLock) { if (!_postedPeers.Add(sender)) { FiresLogger.LogVerbose("[ClientLogRelay] Dropping duplicate submission from '" + playerName + "' (" + platformId + ") — already posted this session."); return; } TimeSpan timeSpan = DateTime.UtcNow - _lastPostUtc; if (timeSpan.TotalSeconds < 10.0) { _postedPeers.Remove(sender); FiresLogger.LogVerbose($"[ClientLogRelay] Rate-limiting post from '{playerName}' ({platformId}); {timeSpan.TotalSeconds:F1}s since last post (floor {10f:F0}s)."); return; } _lastPostUtc = DateTime.UtcNow; } Dictionary<string, string> dictionary3 = null; try { dictionary3 = ClientLogCollector.BuildLocalModList(); } catch (Exception ex) { FiresLogger.LogVerbose("[ClientLogRelay] Could not enumerate server-side mod list: " + ex.GetType().Name + ": " + ex.Message); } ClientLogArtifacts artifacts = new ClientLogArtifacts(platformId, playerName, fullLog, (IReadOnlyDictionary<string, string>)dictionary2, (IReadOnlyDictionary<string, string>)dictionary3, "FiresDiscordIntegration"); lock (_debounceLock) { _peerToPlatformId[sender] = platformId; _peerToPlayerName[sender] = playerName; } int clientModCount = dictionary2.Count; int serverModCount = dictionary3?.Count ?? 0; ClientLogRelayWorker.Enqueue(delegate { ClientLogRelay.ReportArtifacts(artifacts); FiresLogger.LogInfo($"[ClientLogRelay] Processed log from '{playerName}' ({platformId}): {fullLog.Length} bytes, {clientModCount} client mods, {serverModCount} server mods."); }); } catch (Exception ex2) { Debug.LogWarning((object)("[ClientLogRelay] Failed to process submitted log: " + ex2.Message)); } } private static void OnServerReceiveLogUpdate(long sender, ZPackage pkg) { try { if (pkg == null || !MasterEnabled) { return; } pkg.SetPos(0); int num = pkg.ReadInt(); pkg.SetPos(0); string text = null; byte[] log; if (num >= 0 && num < 1000) { int num2 = pkg.ReadInt(); int num3 = pkg.ReadInt(); byte[] array = pkg.ReadByteArray(); try { text = pkg.ReadString(); } catch { } FiresLogger.LogVerbose($"[ClientLogRelay] ← Received update chunk {num2 + 1}/{num3} from peer {sender} ({array.Length}B)"); TransferResult val = ClientLogChunkedTransfer.ReceiveUpdateChunk(sender, num2, num3, array, text); if (val == null) { return; } log = val.LogBytes; text = val.SteamId; FiresLogger.LogVerbose($"[ClientLogRelay] ← Complete update received from peer {sender}: {log.Length}B"); } else { log = pkg.ReadByteArray(); try { text = pkg.ReadString(); } catch { } } string playerName = GetPeerPlayerName(sender); string platformId = ((!string.IsNullOrEmpty(text)) ? text : sender.ToString()); if (log == null || log.Length == 0) { FiresLogger.LogVerbose("[ClientLogRelay] Update from '" + playerName + "' (" + platformId + ") had empty log — skipping."); return; } string logsRoot = ClientLogsRoot; if (!string.IsNullOrEmpty(logsRoot)) { Dictionary<string, string> serverMods = null; try { serverMods = ClientLogCollector.BuildLocalModList(); } catch { } ClientLogRelayWorker.Enqueue(delegate { RewriteCachedArtifacts(logsRoot, platformId, playerName, log, serverMods); }); } lock (_debounceLock) { _peerToPlatformId[sender] = platformId; _peerToPlayerName[sender] = playerName; } } catch (Exception ex) { Debug.LogWarning((object)("[ClientLogRelay] Failed to process log update: " + ex.Message)); } } private static void RewriteCachedArtifacts(string logsRoot, string platformId, string playerName, byte[] log, Dictionary<string, string> serverMods) { //IL_00f9: Unknown result type (might be due to invalid IL or missing references) //IL_0100: Expected O, but got Unknown Dictionary<string, string> dictionary = new Dictionary<string, string>(0, StringComparer.OrdinalIgnoreCase); string text = playerName ?? "unknown"; char[] invalidFileNameChars = Path.GetInvalidFileNameChars(); text = string.Concat(text.Split(invalidFileNameChars)); string text2 = (platformId ?? "unknown").Replace(":", "_").Replace("/", "_").Replace("\\", "_"); string path = Path.Combine(logsRoot, text + "_" + text2, "modlist.txt"); if (File.Exists(path)) { try { string[] array = File.ReadAllLines(path); foreach (string text3 in array) { if (!string.IsNullOrEmpty(text3) && !text3.StartsWith("#")) { int num = text3.IndexOf('='); if (num > 0) { dictionary[text3.Substring(0, num)] = text3.Substring(num + 1); } } } } catch { } } ClientLogArtifacts val = new ClientLogArtifacts(platformId, playerName, log, (IReadOnlyDictionary<string, string>)dictionary, (IReadOnlyDictionary<string, string>)serverMods, "FiresDiscordIntegration"); ClientLogRelay.AnalyzeLog(val); if (dictionary.Count > 0) { ClientLogRelay.ComputeModDiff(val); } ClientLogArtifactWriter.Write(logsRoot, val); FiresLogger.LogVerbose($"[ClientLogRelay] ← Updated all cached artifacts for '{playerName}' ({platformId}): {log.Length} bytes."); } [HarmonyPatch(typeof(ZNet), "Disconnect")] [HarmonyPrefix] public static void ZNet_Disconnect_Prefix(ZNetPeer peer) { try { if (!_isServerSide || peer == null) { return; } ClientLogChunkedTransfer.CancelTransfer(peer.m_uid); string value; string value2; lock (_debounceLock) { _peerToPlatformId.TryGetValue(peer.m_uid, out value); _peerToPlayerName.TryGetValue(peer.m_uid, out value2); _peerToPlatformId.Remove(peer.m_uid); _peerToPlayerName.Remove(peer.m_uid); _postedPeers.Remove(peer.m_uid); } if (string.IsNullOrEmpty(value)) { value = peer.m_uid.ToString(); } if (string.IsNullOrEmpty(value2)) { value2 = (string.IsNullOrEmpty(peer.m_playerName) ? "unknown" : peer.m_playerName); } Entry val = DisconnectLogRegistry.TakeIfRegistered(value); if (val != null) { Debug.Log((object)("[ClientLogRelay] Player '" + value2 + "' (" + value + ") disconnected — posting session log (requested by " + val.RequestedByName + ").")); if ((Object)(object)ZNet.instance != (Object)null) { ((MonoBehaviour)ZNet.instance).StartCoroutine(PostDisconnectLogAsync(value, value2, val.RequestedByName)); } } } catch (Exception ex) { Debug.LogWarning((object)("[ClientLogRelay] Disconnect hook threw: " + ex.Message)); } } private static IEnumerator PostDisconnectLogAsync(string platformId, string playerName, string requestedByName) { yield return (object)new WaitForSeconds(1f); ClientLogRelayWorker.Enqueue(delegate { byte[] logBytes = ReadCachedSessionLog(platformId, playerName); if (logBytes != null) { MainThreadDispatcher.Enqueue((Action)delegate { PostDisconnectLog(platformId, playerName, requestedByName, logBytes); }); } }); } private static byte[] ReadCachedSessionLog(string platformId, string playerName) { try { string clientLogsRoot = ClientLogsRoot; if (string.IsNullOrEmpty(clientLogsRoot)) { return null; } string text = playerName ?? "unknown"; char[] invalidFileNameChars = Path.GetInvalidFileNameChars(); text = string.Concat(text.Split(invalidFileNameChars)); string text2 = (platformId ?? "unknown").Replace(":", "_").Replace("/", "_").Replace("\\", "_"); string text3 = Path.Combine(clientLogsRoot, text + "_" + text2, "LogOutput.log"); if (!File.Exists(text3)) { FiresLogger.LogWarning("[ClientLogRelay] No cached log at '" + text3 + "' for disconnect post."); return null; } byte[] array = File.ReadAllBytes(text3); return (array.Length == 0) ? null : array; } catch (Exception ex) { FiresLogger.LogWarning("[ClientLogRelay] Failed to read the cached log for the disconnect post: " + ex.Message); return null; } } private static void PostDisconnectLog(string platformId, string playerName, string requestedByName, byte[] logBytes) { try { string text = (platformId ?? "unknown").Replace(":", "_").Replace("/", "_").Replace("\\", "_"); List<string> list = new List<string>(2); string[] array = new string[2] { PrimaryWebhookUrl, SecondaryWebhookUrl }; foreach (string text2 in array) { if (!string.IsNullOrEmpty(text2) && MinimalWebhookPoster.IsValidWebhookUrl(text2)) { list.Add(text2); } } if (list.Count == 0) { return; } string text3 = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss"); MinimalWebhookPoster.Embed embed = new MinimalWebhookPoster.Embed().SetTitle("♻\ufe0f Session Log — Player Disconnected").SetColor(3066993).AddField("\ud83d\udc64 Player", playerName, inline: true) .AddField("\ud83d\udd94 Steam ID", platformId ?? "?", inline: true) .AddField("\ud83d\udcc4 Log Size", FormatBytes(logBytes.Length), inline: true) .SetFooter("Requested by " + requestedByName); if (logBytes.Length <= 8323072) { List<MinimalWebhookPoster.Attachment> list2 = new List<MinimalWebhookPoster.Attachment>(); list2.Add(new MinimalWebhookPoster.Attachment("session_log_" + text + "_" + text3 + ".log", logBytes)); List<MinimalWebhookPoster.Attachment> files = list2; foreach (string item in list) { MinimalWebhookPoster.Post(item, embed, files, ResolveWebhookName()); } } else { int num = 0; int num2 = 0; while (num2 < logBytes.Length) { num++; int num3 = Math.Min(8323072, logBytes.Length - num2); byte[] array2 = new byte[num3]; Buffer.BlockCopy(logBytes, num2, array2, 0, num3); num2 += num3; int num4 = (logBytes.Length + 8323072 - 1) / 8323072; MinimalWebhookPoster.Embed embed2 = new MinimalWebhookPoster.Embed().SetTitle($"♻\ufe0f Session Log — Part {num}/{num4}").SetColor(3066993).AddField("\ud83d\udc64 Player", playerName, inline: true) .AddField("\ud83d\udcc4 Size", FormatBytes(array2.Length), inline: true) .SetFooter("Requested by " + requestedByName); List<MinimalWebhookPoster.Attachment> files2 = new List<MinimalWebhookPoster.Attachment> { new MinimalWebhookPoster.Attachment($"session_log_{text}_{text3}_part{num}.log", array2) }; foreach (string item2 in list) { MinimalWebhookPoster.Post(item2, embed2, files2, ResolveWebhookName()); } } } Debug.Log((object)$"[ClientLogRelay] Posted disconnect session log for '{playerName}' ({platformId}), {logBytes.Length} bytes."); } catch (Exception ex) { Debug.LogWarning((object)("[ClientLogRelay] Failed to post disconnect log: " + ex.Message)); } } private static string FormatBytes(long bytes) { if (bytes <= 0) { return "0 B"; } string[] array = new string[4] { "B", "KB", "MB", "GB" }; double num = bytes; int num2 = 0; while (num >= 1024.0 && num2 < array.Length - 1) { num /= 1024.0; num2++; } return $"{num:0.##} {array[num2]}"; } private static string GetPeerPlayerName(long uid) { if ((Object)(object)ZNet.instance == (Object)null) { return "unknown"; } foreach (ZNetPeer peer in ZNet.instance.GetPeers()) { if (peer != null && peer.m_uid == uid) { return string.IsNullOrEmpty(peer.m_playerName) ? "unknown" : peer.m_playerName; } } return "unknown"; } private static string GetOwnSteamId() { try { Type type = Type.GetType("Steamworks.SteamAPI, Steamworks.NET") ?? AccessTools.TypeByName("Steamworks.SteamAPI"); Type type2 = Type.GetType("Steamworks.SteamUser, Steamworks.NET") ?? AccessTools.TypeByName("Steamworks.SteamUser"); if (type == null || type2 == null) { return string.Empty; } MethodInfo method = type.GetMethod("IsSteamRunning", BindingFlags.Static | BindingFlags.Public); if (method == null) { return string.Empty; } object obj = method.Invoke(null, null); if (!(obj is bool) || !(bool)obj) { return string.Empty; } MethodInfo method2 = type2.GetMethod("GetSteamID", BindingFlags.Static | BindingFlags.Public); if (method2 == null) { return string.Empty; } object obj2 = method2.Invoke(null, null); if (obj2 == null) { return string.Empty; } FieldInfo field = obj2.GetType().GetField("m_SteamID", BindingFlags.Instance | BindingFlags.Public); if (field != null) { object value = field.GetValue(obj2); if (value != null) { return value.ToString(); } } return obj2.ToString(); } catch { } return string.Empty; } } internal static class ClientLogRelayWorker { private const int DrainTimeoutMs = 10000; private const string ThreadName = "FiresDiscordIntegration.ClientLogRelay"; private static readonly BlockingCollection<Action> _jobs = new BlockingCollection<Action>(); private static readonly object _startLock = new object(); private static Thread _thread; private static int _queued; private static bool _prepared; internal static bool OnWorkerThread { get { if (_thread != null) { return Thread.CurrentThread == _thread; } return false; } } internal static void Prepare() { if (!_prepared) { _prepared = true; MainThreadDispatcher.Enqueue((Action)delegate { }); } } internal static void Enqueue(Action job) { if (job == null) { return; } EnsureThread(); Interlocked.Increment(ref _queued); try { _jobs.Add(job); } catch (InvalidOperationException) { Run(job); } } internal static void Drain() { if (_thread == null) { return; } Stopwatch stopwatch = Stopwatch.StartNew(); ManualResetEventSlim done = new ManualResetEventSlim(initialState: false); try { _jobs.Add(delegate { done.Set(); }); } catch (InvalidOperationException) { return; } if (done.Wait(10000)) { Debug.Log((object)$"[ClientLogRelay] shutdown: relayed-log queue drained in {stopwatch.ElapsedMilliseconds} ms ({_queued} job(s) this session)."); } else { Debug.LogWarning((object)$"[ClientLogRelay] relayed logs still writing after {10} s at shutdown."); } } private static void EnsureThread() { lock (_startLock) { if (_thread == null) { _thread = new Thread(Loop) { IsBackground = true, Name = "FiresDiscordIntegration.ClientLogRelay" }; _thread.Start(); } } } private static void Loop() { foreach (Action item in _jobs.GetConsumingEnumerable()) { Run(item); } } private static void Run(Action job) { try { job(); } catch (Exception ex) { FiresLogger.LogWarning("[ClientLogRelay] background job failed: " + ex.Message); } } } } namespace FiresDiscordIntegration.ClientLogRelay.Webhook { public static class MinimalWebhookPoster { public sealed class Embed { public string Title; public string Description; public int Color = 3447003; public string Footer; public string ImageUrl; public readonly List<(string name, string value, bool inline)> Fields = new List<(string, string, bool)>(); public Embed SetTitle(string title) { Title = title; return this; } public Embed SetDescription(string desc) { Description = desc; return this; } public Embed SetColor(int rgbInt) { Color = rgbInt; return this; } public Embed SetFooter(string footer) { Footer = footer; return this; } public Embed SetImage(string url) { ImageUrl = url; return this; } public Embed AddField(string name, string value, bool inline = false) { Fields.Add((name, value, inline)); return this; } } public sealed class Attachment { public string FileName; public byte[] Content; public string ContentType = "text/plain"; public Attachment(string fileName, byte[] bytes, string contentType = "text/plain") { FileName = fileName; Content = bytes ?? Array.Empty<byte>(); ContentType = contentType; } public Attachment(string fileName, string text, string contentType = "text/plain") : this(fileName, Encoding.UTF8.GetBytes(text ?? string.Empty), contentType) { } } public sealed class CoroutineHost : MonoBehaviour { } private const string BoundaryPrefix = "------ClientLogRelayBoundary"; private const float ReactionPutCooldownSeconds = 0.5f; private const float MessageDeleteCooldownSeconds = 0.5f; private const float PostMessageCleanupDelaySeconds = 2f; private static CoroutineHost _host; private static bool s_noUrlNoted; private static readonly HashSet<string> s_badUrlsWarned = new HashSet<string>(StringComparer.Ordinal); private const int UrlHintLength = 24; private static void NoteSkippedPost(string url) { if (string.IsNullOrWhiteSpace(url)) { if (!s_noUrlNoted) { s_noUrlNoted = true; FiresLogger.LogDebug("[ClientLogRelay] No webhook URL set: relay posts are off."); } } else if (s_badUrlsWarned.Add(url)) { string arg = ((url.Length > 24) ? (url.Substring(0, 24) + "...") : url); FiresLogger.LogWarning($"[ClientLogRelay] Webhook URL is not a Discord webhook (\"{arg}\", {url.Length} characters); " + "posts to it are skipped. Expected https://discord.com/api/webhooks/... or a discord-bot-channel:<id> target."); } } public static bool IsValidWebhookUrl(string url) { if (string.IsNullOrEmpty(url) || !url.StartsWith("https://", StringComparison.Ordinal)) { return false; } if (url.IndexOf("discord.com/api/webhooks/", StringComparison.Ordinal) < 0) { return url.IndexOf("discordapp.com/api/webhooks/", StringComparison.Ordinal) >= 0; } return true; } public static void Post(string webhookUrl, Embed embed, List<Attachment> files, string username = null, string avatarUrl = null) { Post(webhookUrl, embed, files, username, avatarUrl, null); } public static void Post(string webhookUrl, Embed embed, List<Attachment> files, string username, string avatarUrl, Action<string, string> onPosted) { string botChannel = null; if (BotChannelTarget.TryParse(webhookUrl, out botChannel)) { username = null; avatarUrl = null; } else if (!IsValidWebhookUrl(webhookUrl)) { NoteSkippedPost(webhookUrl); onPosted?.Invoke(null, null); return; } ClientLogRelayWorker.Prepare(); ClientLogRelayWorker.Enqueue(delegate { string boundary = "------ClientLogRelayBoundary" + Guid.NewGuid().ToString("N"); byte[] body = null; try { body = BuildMultipart(boundary, embed, files, username, avatarUrl); } catch (Exception ex) { FiresLogger.LogWarning("[ClientLogRelay] Webhook body build failed: " + ex.Message); } MainThreadDispatcher.Enqueue((Action)delegate { if (body == null) { onPosted?.Invoke(null, null); } else { EnsureHost(); ((MonoBehaviour)_host).StartCoroutine(PostCoroutine(webhookUrl, boundary, body, onPosted, botChannel)); } }); }); } private static IEnumerator PostCoroutine(string webhookUrl, string boundary, byte[] body, Action<string, string> onPosted, string botChannel = null) { string text = ((botChannel != null) ? BotChannelTarget.MessagesUrl(botChannel) : ((webhookUrl.IndexOf('?') >= 0) ? (webhookUrl + "&wait=true") : (webhookUrl + "?wait=true"))); UnityWebRequest req = new UnityWebRequest(text, "POST"); try { req.uploadHandler = (UploadHandler)new UploadHandlerRaw(body); req.downloadHandler = (DownloadHandler)new DownloadHandlerBuffer(); req.SetRequestHeader("Content-Type", "multipart/form-data; boundary=" + boundary); if (botChannel != null) { req.SetRequestHeader("Authorization", BotChannelTarget.AuthHeader()); } req.timeout = 30; yield return req.SendWebRequest(); if ((int)req.result == 2 || (int)req.result == 3) { string text2 = null; try { DownloadHandler downloadHandler = req.downloadHandler; text2 = ((downloadHandler != null) ? downloadHandler.text : null); } catch { } string text3 = (string.IsNullOrEmpty(text2) ? "<empty>" : ((text2.Length > 500) ? (text2.Substring(0, 500) + "…") : text2)); Debug.LogWarning((object)((botChannel != null) ? $"[Discord] test shot to channel {BotChannelTarget.Short(botChannel)} (bot) FAILED ({req.responseCode}): {req.error} | body: {text3}" : $"[ClientLogRelay] Webhook POST failed ({req.responseCode}): {req.error} | body: {text3}")); onPosted?.Invoke(null, null); yield break; } if (botChannel != null) { Debug.Log((object)("[Discord] test shot posted to channel " + BotChannelTarget.Short(botChannel) + " (bot)")); } string arg = null; string arg2 = null; try { DownloadHandler downloadHandler2 = req.downloadHandler; string text4 = ((downloadHandler2 != null) ? downloadHandler2.text : null); if (!string.IsNullOrEmpty(text4)) { Dictionary<string, object> dictionary = JsonConvert.DeserializeObject<Dictionary<string, object>>(text4); if (dictionary != null) { if (dictionary.TryGetValue("id", out var value) && value != null) { arg = value.ToString(); } if (dictionary.TryGetValue("channel_id", out var value2) && value2 != null) { arg2 = value2.ToString(); } } } } catch (Exception ex) { Debug.LogWarning((object)("[ClientLogRelay] Webhook response parse failed: " + ex.Message)); } try { onPosted?.Invoke(arg, arg2); } catch (Exception ex2) { Debug.LogWarning((object)("[ClientLogRelay] onPosted callback threw: " + ex2.Message)); } } finally { ((IDisposable)req)?.Dispose(); } } private static byte[] BuildMultipart(string boundary, Embed embed, List<Attachment> files, string username, string avatarUrl) { Dictionary<string, object> dictionary = new Dictionary<string, object>(); if (!string.IsNullOrEmpty(username)) { dictionary["username"] = username; } if (!string.IsNullOrEmpty(avatarUrl)) { dictionary["avatar_url"] = avatarUrl; } if (embed != null) { Dictionary<string, object> dictionary2 = new Dictionary<string, object>(); if (!string.IsNullOrEmpty(embed.Title)) { dictionary2["title"] = embed.Title; } if (!string.IsNullOrEmpty(embed.Description)) { dictionary2["description"] = embed.Description; } dictionary2["color"] = embed.Color; if (!string.IsNullOrEmpty(embed.Footer)) { dictionary2["footer"] = new Dictionary<string, object> { ["text"] = embed.Footer }; } if (!string.IsNullOrEmpty(embed.ImageUrl)) { dictionary2["image"] = new Dictionary<string, object> { ["url"] = embed.ImageUrl }; } if (embed.Fields.Count > 0) { List<object> list = new List<object>(embed.Fields.Count); foreach (var field in embed.Fields) { list.Add(new Dictionary<string, object> { ["name"] = field.name ?? string.Empty, ["value"] = (string.IsNullOrEmpty(field.value) ? "-" : field.value), ["inline"] = field.inline }); } dictionary2["fields"] = list; } dictionary["embeds"] = new Dictionary<string, object>[1] { dictionary2 }; } string s = JsonConvert.SerializeObject((object)dictionary); byte[] bytes = Encoding.UTF8.GetBytes("--" + boundary + "\r\n"); byte[] bytes2 = Encoding.UTF8.GetBytes("\r\n"); byte[] bytes3 = Encoding.UTF8.GetBytes("--" + boundary + "--\r\n"); List<byte[]> list2 = new List<byte[]>(6 + (files?.Count ?? 0) * 4); list2.Add(bytes); list2.Add(Encoding.UTF8.GetBytes("Content-Disposition: form-data; name=\"payload_json\"\r\nContent-Type: application/json\r\n\r\n")); list2.Add(Encoding.UTF8.GetBytes(s)); list2.Add(bytes2); if (files != null) { for (int i = 0; i < files.Count; i++) { Attachment attachment = files[i]; list2.Add(bytes); string s2 = $"Content-Disposition: form-data; name=\"files[{i}]\"; filename=\"{attachment.FileName}\"\r\n" + "Content-Type: " + attachment.ContentType + "\r\n\r\n"; list2.Add(Encoding.UTF8.GetBytes(s2)); list2.Add(attachment.Content); list2.Add(bytes2); } } list2.Add(bytes3); int num = 0; foreach (byte[] item in list2) { num += item.Length; } byte[] array = new byte[num]; int num2 = 0; foreach (byte[] item2 in list2) { Buffer.BlockCopy(item2, 0, array, num2, item2.Length); num2 += item2.Length; } return array; } public static void AddReaction(string botToken, string channelId, string messageId, string emoji) { if (!string.IsNullOrEmpty(botToken) && !string.IsNullOrEmpty(channelId) && !string.IsNullOrEmpty(messageId) && !string.IsNullOrEmpty(emoji)) { EnsureHost(); ((MonoBehaviour)_host).StartCoroutine(AddReactionCoroutine(botToken, channelId, messageId, emoji)); } } public static void AddReactions(string botToken, string channelId, string messageId, params string[] emojis) { if (!string.IsNullOrEmpty(botToken) && !string.IsNullOrEmpty(channelId) && !string.IsNullOrEmpty(messageId) && emojis != null && emojis.Length != 0) { EnsureHost(); ((MonoBehaviour)_host).StartCoroutine(AddReactionsCoroutine(botToken, channelId, messageId, emojis)); } } private static IEnumerator AddReactionsCoroutine(string botToken, string channelId, string messageId, string[] emojis) { for (int i = 0; i < emojis.Length; i++) { if (!string.IsNullOrEmpty(emojis[i])) { yield return AddReactionCoroutine(botToken, channelId, messageId, emojis[i]); } } } private static IEnumerator AddReactionCoroutine(string botToken, string channelId, string messageId, string emoji) { string text = Uri.EscapeDataString(emoji); string text2 = "https://discord.com/api/v10/channels/" + channelId + "/messages/" + messageId + "/reactions/" + text + "/@me"; UnityWebRequest req = new UnityWebRequest(text2, "PUT"); try { req.downloadHandler = (DownloadHandler)new DownloadHandlerBuffer(); req.SetRequestHeader("Authorization", "Bot " + botToken); req.timeout = 15; yield return req.SendWebRequest(); if ((int)req.result == 2 || (int)req.result == 3) { Debug.LogWarning((object)$"[ClientLogRelay] AddReaction failed ({req.responseCode}): {req.error}"); } else { Debug.Log((object)("[ClientLogRelay] Bot pre-reacted with " + emoji + " on message " + messageId)); } } finally { ((IDisposable)req)?.Dispose(); } yield return (object)new WaitForSeconds(0.5f); } public static void StartCleanupOldClientLogs(string botToken, string channelId, int keepCount = 10) { if (!string.IsNullOrEmpty(botToken) && !string.IsNullOrEmpty(channelId)) { EnsureHost(); ((MonoBehaviour)_host).StartCoroutine(CleanupOldClientLogsCoroutine(botToken, channelId, keepCount)); } } private static IEnumerator CleanupOldClientLogsCoroutine(string botToken, string channelId, int keepCount) { yield return (object)new WaitForSeconds(2f); string text = "https://discord.com/api/v10/channels/" + channelId + "/messages?limit=50"; UnityWebRequest req = UnityWebRequest.Get(text); try { req.SetRequestHeader("Authorization", "Bot " + botToken); req.timeout = 15; yield return req.SendWebRequest(); if ((int)req.result == 2 || (int)req.result == 3) { if (req.responseCode != 403 && req.responseCode != 404) { Debug.LogWarning((object)$"[ClientLogRelay] Failed to fetch messages for cleanup ({req.responseCode})"); } yield break; } JArray val; try { val = JArray.Parse(req.downloadHandler.text); } catch (Exception ex) { Debug.LogWarning((object)("[ClientLogRelay] Failed to parse messages for cleanup: " + ex.Message)); yield break; } if (val == null || ((JContainer)val).Count <= keepCount) { yield break; } List<string> clientLogMessages = new List<string>(); foreach (JToken item in val) { JObject val2 = (JObject)(object)((item is JObject) ? item : null); if (val2 == null) { continue; } JToken obj = val2["author"]; JObject val3 = (JObject)(object)((obj is JObject) ? obj : null); if (val3 == null) { continue; } JToken val4 = val3["bot"]; if (val4 == null || (int)val4.Type != 9 || !(bool)val4) { continue; } JToken obj2 = val2["embeds"]; JArray val5 = (JArray)(object)((obj2 is JArray) ? obj2 : null); if (val5 == null || ((JContainer)val5).Count == 0) { continue; } bool flag = false; foreach (JToken item2 in val5) { JToken obj3 = ((item2 is JObject) ? item2 : null); string text2 = ((obj3 == null) ? null : ((object)((JObject)obj3)["title"])?.ToString()) ?? ""; string text3 = ((obj3 == null) ? null : ((object)((JObject)obj3)["description"])?.ToString()) ?? ""; if (text2.Contains("Client") || text2.Contains("Login") || text2.Contains("Snapshot") || text3.Contains("Platform ID") || text3.Contains("Captured")) { flag = true; break; } } if (flag) { string text4 = ((object)val2["id"])?.ToString(); if (!string.IsNullOrEmpty(text4)) { clientLogMessages.Add(text4); } } } int num = clientLogMessages.Count - keepCount; if (num <= 0) { yield break; } Debug.Log((object)$"[ClientLogRelay] Found {clientLogMessages.Count} client log messages, deleting oldest {num} to keep {keepCount}"); for (int i = keepCount; i < clientLogMessages.Count; i++) { string messageId = clientLogMessages[i]; string text5 = "https://discord.com/api/v10/channels/" + channelId + "/messages/" + messageId; UnityWebRequest delReq = UnityWebRequest.Delete(text5); try { delReq.SetRequestHeader("Authorization", "Bot " + botToken); delReq.timeout = 10; yield return delReq.SendWebRequest(); if (((int)delReq.result == 2 || (int)delReq.result == 3) && delReq.responseCode != 404 && delReq.responseCode != 403) { Debug.LogWarning((object)$"[ClientLogRelay] Failed to delete message {messageId} ({delReq.responseCode})"); } } finally { ((IDisposable)delReq)?.Dispose(); } yield return (object)new WaitForSeconds(0.5f); } Debug.Log((object)$"[ClientLogRelay] Client log cleanup complete - kept {keepCount} most recent messages"); } finally { ((IDisposable)req)?.Dispose(); } } private static void EnsureHost() { //IL_0013: Unknown result type (might be due to invalid IL or missing references) //IL_0018: Unknown result type (might be due to invalid IL or missing references) //IL_001e: Expected O, but got Unknown if (!((Object)(object)_host != (Object)null)) { GameObject val = new GameObject("ClientLogRelay_WebhookHost"); Object.DontDestroyOnLoad((Object)val); _host = val.AddComponent<CoroutineHost>(); } } } } namespace FiresDiscordIntegration.ClientLogRelay.Interactions { public sealed class ReactionPoller : MonoBehaviour { public const string ServerListConsumerId = "vangarde_serverlists"; private const float POLL_INTERVAL_SECONDS = 60f; private const float RATE_LIMIT_BACKOFF_SECONDS = 300f; private static readonly TimeSpan PostRegistrationWarmupSeconds = TimeSpan.FromSeconds(90.0); private const float InitialPollStartupDelaySeconds = 30f; private const float InterEmojiPollGapSeconds = 0.75f; private const float InterMessagePollGapSeconds = 3f; private const float PostPublishCooldownSeconds = 1.5f; private static int _consecutiveRateLimits = 0; private static float _lastRateLimitTime = 0f; private const int DiscordFreeWebhookFileLimitBytes = 8388608; private const int MultipartEnvelopeMarginBytes = 65536; private const int MAX_ATTACHMENT_BYTES = 8323072; private Func<string> _botTokenResolver; private Func<string> _webhookUrlResolver; private Func<string> _clientLogsRootResolver; private Func<string> _webhookNameResolver; private readonly HashSet<string> _fulfilled = new HashSet<string>(StringComparer.Ordinal); private string _botUserId; private bool _botUserIdResolved; private static readonly string[] _emojiRaw = new string[4] { "\ud83d\udce9", "⛔", "\ud83e\udde9", "♻\ufe0f" }; private static readonly string[] _emojiEncoded = _emojiRaw.Select((string e) => Uri.EscapeDataString(e)).ToArray(); private static string EmojiLabel(string emoji) { return emoji switch { "\ud83d\udce9" => "LOG", "⛔" => "ERRORS", "\ud83e\udde9" => "MODS", "♻\ufe0f" => "DISCONNECT", "\ud83d\udd04" => "RESTART", _ => emoji, }; } public static ReactionPoller Create(Func<string> botTokenResolver, Func<string> webhookUrlResolver, Func<string> clientLogsRootResolver, Func<string> webhookNameResolver = null) { //IL_0005: Unknown result type (might be due to invalid IL or missing references) //IL_000a: Unknown result type (might be due to invalid IL or missing references) //IL_0010: Expected O, but got Unknown GameObject val = new GameObject("ClientLogRelay_ReactionPoller"); Object.DontDestroyOnLoad((Object)val); ReactionPoller reactionPoller = val.AddComponent<ReactionPoller>(); reactionPoller._botTokenResolver = botTokenResolver; reactionPoller._webhookUrlResolver = webhookUrlResolver; reactionPoller._clientLogsRootResolver = clientLogsRootResolver; reactionPoller._webhookNameResolver = webhookNameResolver; return reactionPoller; } private void Start() { ((MonoBehaviour)this).StartCoroutine(PollLoop()); } private IEnumerator PollLoop() { yield return (object)new WaitForSeconds(30f); while (true) { if (_consecutiveRateLimits > 0) { float num = Time.realtimeSinceStartup - _lastRateLimitTime; if (num < 300f) { float num2 = 300f - num; Debug.Log((object)$"[ClientLogRelay] ReactionPoller: Rate limited, backing off for {num2:F0}s (attempt {_consecutiveRateLimits})"); yield return (object)new WaitForSeconds(num2); } } yield return (object)new WaitForSeconds(60f); string botToken = _botTokenResolver?.Invoke(); if (string.IsNullOrEmpty(botToken)) { continue; } if (!_botUserIdResolved) { yield return ResolveBotUserId(botToken); _botUserIdResolved = true; } LogRequestContext[] array = LogRequestRegistry.Snapshot(); if (array == null || array.Length == 0) { continue; } int polledCount = 0; int skippedWarmup = 0; LogRequestContext[] array2 = array; foreach (LogRequestContext ctx in array2) { if (string.IsNullOrEmpty(ctx.ChannelId) || string.IsNullOrEmpty(ctx.MessageId)) { continue; } if (LogRequestRegistry.IsWithinRegistrationWindow(ctx.MessageId, PostRegistrationWarmupSeconds)) { skippedWarmup++; continue; } bool hitRateLimit = false; for (int j = 0; j < _emojiRaw.Length; j++) { bool wasRateLimited = false; yield return PollReaction(botToken, ctx, _emojiRaw[j], _emojiEncoded[j], delegate(bool limited) { wasRateLimited = limited; }); if (wasRateLimited) { hitRateLimit = true; Debug.Log((object)$"[ClientLogRelay] ReactionPoller: Hit rate limit after {polledCount} messages, pausing for this cycle"); break; } if (j < _emojiRaw.Length - 1) { yield return (object)new WaitForSeconds(0.75f); } } if (hitRateLimit) { break; } polledCount++; yield return (object)new WaitForSeconds(3f); } if (polledCount > 0 || skippedWarmup > 0) { string arg = ((skippedWarmup > 0) ? $" (skipped {skippedWarmup} warming-up)" : ""); FiresLogger.LogVerbose($"[ClientLogRelay] ReactionPoller: Polled {polledCount} message(s) this cycle{arg}"); } } } private IEnumerator ResolveBotUserId(string botToken) { UnityWebRequest req = UnityWebRequest.Get("https://discord.com/api/v10/users/@me"); try { req.SetRequestHeader("Authorization", "Bot " + botToken); req.timeout = 15; yield return req.SendWebRequest(); if ((int)req.result != 2 && (int)req.result != 3 && req.downloadHandler != null) { try { Dictionary<string, object> dictionary = JsonConvert.DeserializeObject<Dictionary<string, object>>(req.downloadHandler.text); if (dictionary != null && dictionary.TryGetValue("id", out var value) && value != null) { _botUserId = value.ToString(); Debug.Log((object)("[ClientLogRelay] ReactionPoller resolved bot user id: " + _botUserId)); } } catch (Exception ex) { Debug.LogWarning((object)("[ClientLogRelay] ReactionPoller /users/@me parse failed: " + ex.Message)); } } else { Debug.LogWarning((object)$"[ClientLogRelay] ReactionPoller /users/@me failed ({req.responseCode}): {req.error}"); } } finally { ((IDisposable)req)?.Dispose(); } } private IEnumerator PollReaction(string botToken, LogRequestContext ctx, string emojiRaw, string emojiEncoded, Action<bool> rateLimitCallback = null) { string text = $"https://discord.com/api/v10/channels/{ctx.ChannelId}/messages/{ctx.MessageId}/reactions/{emojiEncoded}?limit={100}"; UnityWebRequest req = UnityWebRequest.Get(text); try { req.SetRequestHeader("Authorization", "Bot " + botToken); req.timeout = 15; yield return req.SendWebRequest(); if ((int)req.result == 2 || (int)req.result == 3) { if (req.responseCode == 429) { _consecutiveRateLimits++; _lastRateLimitTime = Time.realtimeSinceStartup; rateLimitCallback?.Invoke(obj: true); Debug.LogWarning((object)$"[ClientLogRelay] ReactionPoller rate limited (429) - backing off (consecutive: {_consecutiveRateLimits})"); } else if (req.responseCode != 404) { Debug.LogWarning((object)$"[ClientLogRelay] ReactionPoller GET reactions failed ({req.responseCode}): {req.error}"); } yield break; } if (_consecutiveRateLimits > 0) { Debug.Log((object)$"[ClientLogRelay] ReactionPoller: Rate limit cleared after {_consecutiveRateLimits} consecutive 429s"); _consecutiveRateLimits = 0; } rateLimitCallback?.Invoke(obj: false); JArray val; try { val = JArray.Parse(req.downloadHandler.text); } catch (Exception ex) { Debug.LogWarning((object)("[ClientLogRelay] ReactionPoller parse failed: " + ex.Message)); yield break; } foreach (JToken item2 in val) { string text2 = ((object)item2[(object)"id"])?.ToString(); if (string.IsNullOrEmpty(text2) || (!string.IsNullOrEmpty(_botUserId) && text2 == _botUserId)) { continue; } string item = ctx.MessageId + ":" + text2 + ":" + emojiRaw; if (!_fulfilled.Contains(item)) { _fulfilled.Add(item); string text3 = ((object)item2[(object)"username"])?.ToString() ?? text2; string text4 = EmojiLabel(emojiRaw); if (!IsRequestAllowed(ctx, text2)) { DiscordReactionAllowlist.LogIgnoredReaction("[ClientLogRelay] ReactionPoller: " + text4 + " from '" + text3 + "' (" + text2 + ") on " + ctx.PlayerName + " (" + ctx.PlatformId + ")"); } else { Debug.Log((object)("[ClientLogRelay] ReactionPoller: '" + text3 + "' reacted " + text4 + " on " + ctx.PlayerName + " (" + ctx.PlatformId + ")")); yield return Fulfill(ctx, emojiRaw, text3, text2); } } } } finally { ((IDisposable)req)?.Dispose(); } } private static bool IsRequestAllowed(LogRequestContext ctx, string userId) { if (!(ctx.ConsumerId == "vangarde_serverlists")) { return DiscordReactionAllowlist.Allows(userId); } return true; } private IEnumerator Fulfill(LogRequestContext ctx, string emoji, string reqByName, string reqById) { if (ctx.ConsumerId == "vangarde_serverlists") { if (emoji == "\ud83e\udde9") { yield return FulfillServerLists(ctx, reqByName); } yield break; } string text = _clientLogsRootResolver?.Invoke(); if (!string.IsNullOrEmpty(text)) { string folder = BuildPlayerFolder(text, ctx); switch (emoji) { case "\ud83d\udce9": yield return FulfillFullLog(ctx, folder, reqByName); break; case "⛔": yield return FulfillErrorsWarnings(ctx, folder, reqByName); break; case "\ud83e\udde9": yield return FulfillMods(ctx, folder, reqByName); break; case "♻\ufe0f": FulfillDisconnect(ctx, reqByName); break; } } } private IEnumerator FulfillFullLog(LogRequestContext ctx, string folder, string reqByName) { string text = Path.Combine(folder, "LogOutput.log"); byte[] logBytes = SafeReadFile(text); if (logBytes == null || logBytes.Length == 0) { Debug.LogWarning((object)("[ClientLogRelay] ReactionPoller: no cached log for " + ctx.PlatformId + " at '" + text + "'")); yield break; } string stamp = ctx.CapturedUtc.ToString("yyyyMMdd_HHmmss"); string safeId = SafePlatformId(ctx); List<byte[]> chunks = ChunkBytes(logBytes, 8323072); for (int i = 0; i < chunks.Count; i++) { string text2 = ((chunks.Count == 1) ? "" : $"_part{i + 1}of{chunks.Count}"); string fileName = "client_log_" + safeId + "_" + stamp + text2 + ".log"; string title = ((chunks.Count == 1) ? "\ud83d\udce9 Full Log — Requested" : $"\ud83d\udce9 Full Log — Part {i + 1}/{chunks.Count}"); MinimalWebhookPoster.Embed embed = new MinimalWebhookPoster.Embed().SetTitle(title).SetColor(3447003).AddField("\ud83d\udc64 Player", ctx.PlayerName, inline: true) .AddField("\ud83d\udd94 Steam ID", ctx.PlatformId ?? "?", inline: true) .AddField("\ud83d\udcc4 Size", FormatBytes(chunks[i].Length), inline: true) .SetFooter("Requested by " + reqByName); List<MinimalWebhookPoster.Attachment> files = new List<MinimalWebhookPoster.Attachment> { new MinimalWebhookPoster.Attachment(fileName, chunks[i]) }; yield return PostAndWait(embed, files); } Debug.Log((object)("[ClientLogRelay] ReactionPoller: posted full log for " + ctx.PlatformId + " " + $"({logBytes.Length} bytes, {chunks.Count} part(s)), requested by {reqByName}")); } private IEnumerator FulfillErrorsWarnings(LogRequestContext ctx, string folder, string reqByName) { byte[] array = SafeReadFile(Path.Combine(folder, "errors_warnings.txt")); if (array == null || array.Length == 0) { Debug.LogWarning((object)("[ClientLogRelay] ReactionPoller: no errors_warnings.txt for " + ctx.PlatformId)); yield break; } string text = ctx.CapturedUtc.ToString("yyyyMMdd_HHmmss"); string text2 = SafePlatformId(ctx); MinimalWebhookPoster.Embed embed = new MinimalWebhookPoster.Embed().SetTitle("⛔ Errors + Warnings — Requested").SetColor(15548997).AddField("\ud83d\udc64 Player", ctx.PlayerName, inline: true) .AddField("\ud83d\udd94 Steam ID", ctx.PlatformId ?? "?", inline: true) .AddField("\ud83d\udcc4 Size", FormatBytes(array.Length), inline: true) .SetFooter("Requested by " + reqByName); List<MinimalWebhookPoster.Attachment> list = new List<MinimalWebhookPoster.Attachment>(); list.Add(new MinimalWebhookPoster.Attachment("errors_warnings_" + text2 + "_" + text + ".txt", array)); List<MinimalWebhookPoster.Attachment> files = list; yield return PostAndWait(embed, files); Debug.Log((object)("[ClientLogRelay] ReactionPoller: posted errors/warnings for " + ctx.PlatformId + ", requested by " + reqByName)); } private IEnumerator FulfillMods(LogRequestContext ctx, string folder, string reqByName) { string text = ctx.CapturedUtc.ToString("yyyyMMdd_HHmmss"); string text2 = SafePlatformId(ctx); byte[] array = SafeReadFile(Path.Combine(folder, "modlist.txt")); byte[] array2 = SafeReadFile(Path.Combine(folder, "mod_diff.txt")); bool flag = array != null && array.Length != 0; bool flag2 = array2 != null && array2.Length != 0; if (!flag && !flag2) { Debug.LogWarning((object)("[ClientLogRelay] ReactionPoller: no mod artifacts for " + ctx.PlatformId)); yield break; } MinimalWebhookPoster.Embed embed = new MinimalWebhookPoster.Embed().SetTitle("\ud83e\udde9 Mod Lists — Requested").SetColor(5763719).AddField("\ud83d\udc64 Player", ctx.PlayerName, inline: true) .AddField("\ud83d\udd94 Steam ID", ctx.PlatformId ?? "?", inline: true) .SetFooter("Requested by " + reqByName); List<MinimalWebhookPoster.Attachment> list = new List<MinimalWebhookPoster.Attachment>(); if (flag) { list.Add(new MinimalWebhookPoster.Attachment("modlist_" + text2 + "_" + text + ".txt", array)); } if (flag2) { list.Add(new MinimalWebhookPoster.Attachment("mod_diff_" + text2 + "_" + text + ".txt", array2)); } yield return PostAndWait(embed, list); Debug.Log((object)("[ClientLogRelay] ReactionPoller: posted mod artifacts for " + ctx.PlatformId + ", requested by " + reqByName)); } private void FulfillDisconnect(LogRequestContext ctx, string reqByName) { DisconnectLogRegistry.Register(ctx.PlatformId, ctx.PlayerName, reqByName); } private IEnumerator FulfillServerLists(LogRequestContext ctx, string reqByName) { string serverListSnapshot = DiscordSink.GetServerListSnapshot(); if (string.IsNullOrEmpty(serverListSnapshot)) { Debug.LogWarning((object)"[ClientLogRelay] ReactionPoller: no server-list snapshot provider available"); yield break; } MinimalWebhookPoster.Embed embed = new MinimalWebhookPoster.Embed().SetTitle("\ud83e\udde9 Server Mod Lists — Requested").SetColor(5763719).SetDescription("Current enforced / whitelist / server-only / blacklist state, live from the server.") .SetFooter("Requested by " + reqByName); List<MinimalWebhookPoster.Attachment> files = new List<MinimalWebhookPoster.Attachment> { new MinimalWebhookPoster.Attachment("vangarde_server_lists.txt", serverListSnapshot) }; string text = DiscordIntegrationConfig.ResolveWebhookUrl(WebhookCategory.AntiCheat); if (string.IsNullOrEmpty(text) || !MinimalWebhookPoster.IsValidWebhookUrl(text)) { text = _webhookUrlResolver?.Invoke(); if (string.IsNullOrEmpty(text) || !MinimalWebhookPoster.IsValidWebhookUrl(text)) { yield break; } } MinimalWebhookPoster.Post(text, embed, files, _webhookNameResolver?.Invoke()); yield return (object)new WaitForSeconds(1.5f); Debug.Log((object)("[ClientLogRelay] ReactionPoller: posted server mod lists, requested by " + reqByName)); } private IEnumerator PostAndWait(MinimalWebhookPoster.Embed embed, List<MinimalWebhookPoster.Attachment> files) { string text = _webhookUrlResolver?.Invoke(); if (!string.IsNullOrEmpty(text) && MinimalWebhookPoster.IsValidWebhookUrl(text)) { MinimalWebhookPoster.Post(text, embed, files, _webhookNameResolver?.Invoke()); yield return (object)new WaitForSeconds(1.5f); } } private static string BuildPlayerFolder(string logsRoot, LogRequestContext ctx) { string text = (ctx.PlatformId ?? "unknown").Replace(":", "_").Replace("/", "_").Replace("\\", "_"); string text2 = ctx.PlayerName ?? "unknown"; if (!string.IsNullOrEmpty(text2)) { char[] invalidFileNameChars = Path.GetInvalidFileNameChars(); text2 = string.Concat(text2.Split(invalidFileNameChars)); } return Path.Combine(logsRoot, text2 + "_" + text); } private static string SafePlatformId(LogRequestContext ctx) { return (ctx.PlatformId ?? "unknown").Replace(":", "_").Replace("/", "_").Replace("\\", "_"); } private static byte[] SafeReadFile(string path) { try { if (!string.IsNullOrEmpty(path) && File.Exists(path)) { return File.ReadAllBytes(path); } } catch (Exception ex) { Debug.LogWarning((object)("[ClientLogRelay] ReactionPoller: read failed '" + path + "': " + ex.Message)); } return null; } private static List<byte[]> ChunkBytes(byte[] data, int maxBytes) { if (data == null || data.Length == 0) { return new List<byte[]>(0); } if (data.Length <= maxBytes) { return new List<byte[]>(1) { data }; } List<byte[]> list = new List<byte[]>(); int num; for (int i = 0; i < data.Length; i += num) { num = Math.Min(maxBytes, data.Length - i); byte[] array = new byte[num]; Buffer.BlockCopy(data, i, array, 0, num); list.Add(array); } return list; } private static string FormatBytes(long bytes) { if (bytes <= 0) { return "0 B"; } string[] array = new string[4] { "B", "KB", "MB", "GB" }; double num = bytes; int num2 = 0; while (num >= 1024.0 && num2 < array.Length - 1) { num /= 1024.0; num2++; } return $"{num:0.##} {array[num2]}"; } } public static class ServerHeartbeat { private class HeartbeatHost : MonoBehaviour { } private const string DiscordApiBase = "https://discord.com/api/v10"; private const float HeartbeatIntervalSeconds = 60f; private const string SentinelFileName = "server_heartbeat.json"; private const string SentinelDirName = "FiresDiscordIntegration"; private const string EMOJI_RESTART = "\ud83d\udd04"; private const string EMOJI_STOP = "⛔"; private static string _sentinelPath; private static string _statusMessageId; private static string _botToken; private static string _channelId; private static string _statusWebhookUrl; private static string _webhookName; private static string _brandLabel; private static string _botUserId; private static Coroutine _heartbeatCoroutine; private static MonoBehaviour _host; private static DateTime _serverStartUtc; private static bool _cleanShutdown; private static readonly HashSet<string> _fulfilledReactions = new HashSet<string>(StringComparer.Ordinal); private const int HeartbeatEditAttempts = 3; private const float HeartbeatRetryBaseSeconds = 2f; private const float HeartbeatRetryMaxSeconds = 20f; private const int HeartbeatWarnAfterFailedTicks = 3; private const float HeartbeatWarnRepeatSeconds = 3600f; private static int _heartbeatFailedTicks; private static string _heartbeatLastError; private static float _heartbeatLastWarnAt = float.NegativeInfinity; private static float _heartbeatHoldUntil; private const int MaxReactionAttempts = 3; private const int HttpStatusRateLimited = 429; private const int HttpStatusSuccessRangeStart = 200; private const int HttpStatusSuccessRangeEndExcl = 300; private const int AddReactionTimeoutSeconds = 10; private const int ResponseBodyLogMaxChars = 300; private const float DefaultRateLimitWaitSeconds = 1f; private const float RateLimitWaitBufferSeconds = 0.15f; internal static bool HeartbeatArmed { get; private set; } internal static bool CrashNotifierArmed { get; private set; } internal static float IntervalSeconds => 60f; public static void OnServerStart(string botToken, string statusWebhookUrl, string channelIdOverride, string webhookName, string brandLabel) { _botToken = botToken; _statusWebhookUrl = statusWebhookUrl; _webhookName = webhookName; _brandLabel = brandLabel; _channelId = channelIdOverride; if (string.IsNullOrEmpty(_botToken) || string.IsNullOrEmpty(_channelId)) { FiresLogger.LogVerbose("[ServerHeartbeat] BotToken or StatusChannelId not set - heartbeat disabled."); InitSentinel(); SpawnCrashNotifier(); return; } HeartbeatArmed = true; _serverStartUtc = DateTime.UtcNow; _cleanShutdown = false; _statusMessageId = null; InitSentinel(); EnsureHost(); if (!((Object)(object)_host == (Object)null)) { FiresLogger.LogVerbose($"[ServerHeartbeat] Starting - server start time: {_serverStartUtc:yyyy-MM-dd HH:mm:ss} UTC"); _host.StartCoroutine(CleanupAndPostNewMessage()); SpawnCrashNotifier(); _host.StartCoroutine(ReactionPollLoop()); } } public static void OnServerStop() { _cleanShutdown = true; try { WriteSentinel("stopped"); } catch { } if (_heartbeatCoroutine != null && (Object)(object)_host != (Object)null) { _host.StopCoroutine(_heartbeatCoroutine); _heartbeatCoroutine = null; } if (!string.IsNullOrEmpty(_statusMessageId) && (!string.IsNullOrEmpty(_statusWebhookUrl) || (!string.IsNullOrEmpty(_botToken) && !string.IsNullOrEmpty(_channelId)))) { try { EditStatusMessageSync(_statusMessageId, BuildOfflineEmbed()); return; } catch (Exception ex) { Debug.LogWarning((object)("[ServerHeartbeat] Failed to edit status on shutdown: " + ex.Message)); return; } } FiresLogger.LogVerbose("[ServerHeartbeat] OnServerStop: no status message id or auth available — skipping offline edit."); } private static IEnumerator CleanupAndPostNewMessage() { FiresLogger.LogVerbose("[ServerHeartbeat] Cleaning up old status messages..."); string text = "https://discord.com/api/v10/channels/" + _channelId + "/messages?limit=50"; UnityWebRequest req = UnityWebRequest.Get(text); try { req.SetRequestHeader("Authorization", "Bot " + _botToken); req.timeout = 15; yield return req.SendWebRequest(); if (req.responseCode == 200) { JArray val; try { val = JArray.Parse(req.downloadHandler.text); } catch (Exception ex) { Debug.LogWarning((object)("[ServerHeartbeat] Failed to parse messages: " + ex.Message)); val = null; } if (val != null && ((JContainer)val).Count > 0) { int deletedCount = 0; foreach (JToken item in val) { JObject val2 = (JObject)(object)((item is JObject) ? item : null); if (val2 == null) { continue; } JToken obj = val2["author"]; JObject val3 = (JObject)(object)((obj is JObject) ? obj : null); if (val3 == null) { continue; } JToken obj2 = val3["bot"]; if (obj2 == null || !Extensions.Value<bool>((IEnumerable<JToken>)obj2)) { continue; } JToken obj3 = val2["embeds"]; JArray val4 = (JArray)(object)((obj3 is JArray) ? obj3 : null); if (val4 == null || ((JContainer)val4).Count == 0) { continue; } bool flag = false; foreach (JToken item2 in val4) { JToken obj4 = ((item2 is JObject) ? item2 : null); string text2 = ((obj4 == null) ? null : ((object)((JObject)obj4)["title"])?.ToString()) ?? ""; if (text2.Contains("Server Online") || text2.Contains("Server Offline") || text2.Contains("Server Restarting") || text2.Contains("Server Stopped") || text2.Contains("Server Crash")) { flag = true; break; } } if (!flag) { continue; } string messageId = ((object)val2["id"])?.ToString(); if (string.IsNullOrEmpty(messageId)) { continue; } string text3 = "https://discord.com/api/v10/channels/" + _channelId + "/messages/" + messageId; UnityWebRequest deleteReq = UnityWebRequest.Delete(text3); try { deleteReq.SetRequestHeader("Authorization", "Bot " + _botToken); deleteReq.timeout = 10; yield return deleteReq.SendWebRequest(); if (deleteReq.responseCode == 204) { deletedCount++; FiresLogger.LogVerbose("[ServerHeartbeat] Deleted old status message: " + messageId); } } finally { ((IDisposable)deleteReq)?.Dispose(); } if (deletedCount > 0 && deletedCount % 3 == 0) { yield return (object)new WaitForSeconds(0.5f); } } if (deletedCount > 0) { FiresLogger.LogVerbose($"[ServerHeartbeat] Cleaned up {deletedCount} old status message(s)");