using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.IO.Compression; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Security; using System.Security.Cryptography; using System.Security.Permissions; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; using BepInEx; using BepInEx.Configuration; using BepInEx.Logging; using HarmonyLib; using Microsoft.CodeAnalysis; using UnityEngine; using UnityEngine.Networking; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)] [assembly: AssemblyTitle("DiscordTools")] [assembly: AssemblyDescription("")] [assembly: AssemblyConfiguration("")] [assembly: AssemblyCompany("")] [assembly: AssemblyProduct("DiscordTools")] [assembly: AssemblyCopyright("Copyright © 2026 warpalicious")] [assembly: AssemblyTrademark("")] [assembly: ComVisible(false)] [assembly: Guid("C89145AB-DF73-440F-8C28-113526403F96")] [assembly: AssemblyFileVersion("1.4.0")] [assembly: TargetFramework(".NETFramework,Version=v4.8", FrameworkDisplayName = ".NET Framework 4.8")] [assembly: SecurityPermission(SecurityAction.RequestMinimum, SkipVerification = true)] [assembly: AssemblyVersion("1.4.0.0")] [module: UnverifiableCode] namespace Microsoft.CodeAnalysis { [CompilerGenerated] [Microsoft.CodeAnalysis.Embedded] internal sealed class EmbeddedAttribute : Attribute { } } namespace System.Runtime.CompilerServices { [CompilerGenerated] [Microsoft.CodeAnalysis.Embedded] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Event | AttributeTargets.Parameter | AttributeTargets.ReturnValue | AttributeTargets.GenericParameter, AllowMultiple = false, Inherited = false)] internal sealed class NullableAttribute : Attribute { public readonly byte[] NullableFlags; public NullableAttribute(byte P_0) { NullableFlags = new byte[1] { P_0 }; } public NullableAttribute(byte[] P_0) { NullableFlags = P_0; } } [CompilerGenerated] [Microsoft.CodeAnalysis.Embedded] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Method | AttributeTargets.Interface | AttributeTargets.Delegate, AllowMultiple = false, Inherited = false)] internal sealed class NullableContextAttribute : Attribute { public readonly byte Flag; public NullableContextAttribute(byte P_0) { Flag = P_0; } } } namespace DiscordTools { internal static class BotApiClient { public static IEnumerator PostLogRoutine(ArchivedLog log) { string botApiUrl = DiscordToolsPlugin.GetBotApiUrl(); string botApiKey = DiscordToolsPlugin.GetBotApiKey(); if (string.IsNullOrWhiteSpace(botApiUrl)) { yield break; } if (string.IsNullOrWhiteSpace(botApiKey)) { string text = "Bot API key is not configured. Saved locally at " + log.RelativeLogPath; LogArchive.MarkBotUploadFailed(log, text); DiscordToolsPlugin.Log.LogWarning((object)text); yield break; } byte[] array; try { array = ReadDecompressedLog(log.LogPath); } catch (Exception ex) { LogArchive.MarkBotUploadFailed(log, ex.Message); DiscordToolsPlugin.Log.LogWarning((object)("Bot API upload failed for " + log.RelativeLogPath + ": " + ex.Message)); yield break; } string text2 = JsonMetadata(log); List list = new List { (IMultipartFormSection)new MultipartFormDataSection("metadata_json", text2, Encoding.UTF8, "application/json"), (IMultipartFormSection)new MultipartFormFileSection("file", array, GetDiscordFileName(log.LogPath), "text/plain") }; UnityWebRequest request = UnityWebRequest.Post(botApiUrl, list); try { request.SetRequestHeader("X-API-Key", botApiKey); request.SetRequestHeader("User-Agent", "DiscordTools/1.0"); yield return request.SendWebRequest(); if ((int)request.result != 1 || request.responseCode < 200 || request.responseCode >= 300) { string text3 = (string.IsNullOrWhiteSpace(request.error) ? ("HTTP " + request.responseCode) : (request.error + " (HTTP " + request.responseCode + ")")); LogArchive.MarkBotUploadFailed(log, text3); DiscordToolsPlugin.Log.LogWarning((object)("Bot API upload failed for " + log.RelativeLogPath + ": " + text3)); } else { DiscordToolsPlugin.Log.LogInfo((object)("Uploaded client log to bot API: " + log.RelativeLogPath)); } } finally { ((IDisposable)request)?.Dispose(); } } private static string JsonMetadata(ArchivedLog log) { return "{\"requestId\":\"" + EscapeJson(log.RequestId) + "\",\"playerId\":\"" + EscapeJson(log.PlayerId) + "\",\"playerName\":\"" + EscapeJson(log.PlayerName) + "\",\"playerFolder\":\"" + EscapeJson(log.PlayerFolder) + "\",\"reason\":\"" + EscapeJson(log.Reason) + "\",\"receivedAtUtc\":\"" + EscapeJson(log.ReceivedAtUtc.ToString("O", CultureInfo.InvariantCulture)) + "\",\"originalBytes\":" + log.OriginalBytes.ToString(CultureInfo.InvariantCulture) + ",\"compressedBytes\":" + log.CompressedBytes.ToString(CultureInfo.InvariantCulture) + ",\"sha256\":\"" + EscapeJson(log.Sha256) + "\",\"serverLogPath\":\"" + EscapeJson(log.RelativeLogPath) + "\"}"; } private static byte[] ReadDecompressedLog(string path) { using FileStream stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); using GZipStream gZipStream = new GZipStream(stream, CompressionMode.Decompress); using MemoryStream memoryStream = new MemoryStream(); gZipStream.CopyTo(memoryStream); return memoryStream.ToArray(); } private static string GetDiscordFileName(string path) { string fileName = Path.GetFileName(path); if (!fileName.EndsWith(".gz", StringComparison.OrdinalIgnoreCase)) { return fileName; } return fileName.Substring(0, fileName.Length - 3); } private static string EscapeJson(string value) { return (value ?? "").Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\r", "\\r") .Replace("\n", "\\n"); } } internal static class ClientLogCommand { private static bool _registered; public static void Register() { //IL_0024: Unknown result type (might be due to invalid IL or missing references) //IL_0035: Unknown result type (might be due to invalid IL or missing references) //IL_0042: Expected O, but got Unknown //IL_0042: Expected O, but got Unknown //IL_003d: Unknown result type (might be due to invalid IL or missing references) if (!_registered) { _registered = true; new ConsoleCommand(DiscordToolsPlugin.CommandName.Value, "[playerNameOrSteamID] - request a full BepInEx log from that connected player", new ConsoleEventFailable(Execute), false, false, true, false, false, new ConsoleOptionsFetcher(GetPlayerOptions), true, true, false); } } private static object Execute(ConsoleEventArgs args) { //IL_00d7: Unknown result type (might be due to invalid IL or missing references) //IL_00de: Expected O, but got Unknown if ((Object)(object)ZNet.instance == (Object)null || !ZNet.instance.IsServer()) { return "This command can only run on the server."; } if (args.Length < 2 || string.IsNullOrWhiteSpace(args.ArgsAll)) { return "Usage: " + DiscordToolsPlugin.CommandName.Value + " {playerNameOrSteamID}"; } ClientLogRpc.Register(); string text = args.ArgsAll.Trim(); List list = PlayerResolver.FindPeers(text); if (list.Count == 0) { return "No connected player matched '" + text + "'."; } if (list.Count > 1) { return "Multiple connected players matched '" + text + "': " + string.Join(", ", list.Select(PlayerResolver.DescribePeer).ToArray()); } ZNetPeer val = list[0]; string text2 = Guid.NewGuid().ToString("N"); ZPackage val2 = new ZPackage(); val2.Write(text2); val2.Write("manual"); val2.Write(DiscordToolsPlugin.ManualRequestTimeoutSeconds.Value); ZRoutedRpc.instance.InvokeRoutedRPC(val.m_uid, "DiscordTools_RequestLog", new object[1] { val2 }); Terminal context = args.Context; if (context != null) { context.AddString("Requested client log from " + PlayerResolver.DescribePeer(val) + "."); } return true; } private static List GetPlayerOptions() { if ((Object)(object)ZNet.instance == (Object)null) { return new List(); } return (from name in (from peer in ZNet.instance.GetConnectedPeers() where peer.IsReady() select peer.m_playerName into name where !string.IsNullOrWhiteSpace(name) select name).Distinct() orderby name select name).ToList(); } } internal static class ClientLogRpc { private static ZRoutedRpc? _registeredRpc; public static void Register() { if (ZRoutedRpc.instance != null && _registeredRpc != ZRoutedRpc.instance) { _registeredRpc = ZRoutedRpc.instance; ZRoutedRpc.instance.Register("DiscordTools_RequestLog", (Action)OnRequestLog); ZRoutedRpc.instance.Register("DiscordTools_LogMeta", (Action)ServerLogReceiver.OnMetadata); ZRoutedRpc.instance.Register("DiscordTools_LogChunk", (Action)ServerLogReceiver.OnChunk); ZRoutedRpc.instance.Register("DiscordTools_LogResult", (Action)ClientLogUploader.OnResult); DiscordToolsPlugin.Log.LogInfo((object)"Registered DiscordTools RPC handlers."); } } private static void OnRequestLog(long sender, ZPackage pkg) { if (!((Object)(object)ZNet.instance == (Object)null) && !ZNet.instance.IsServer()) { string requestId = pkg.ReadString(); string reason = pkg.ReadString(); int timeoutSeconds = pkg.ReadInt(); ClientLogUploader.StartUpload(reason, requestId, timeoutSeconds, null); } } } internal static class ClientLogUploader { private sealed class PreparedLog { public string RequestId = ""; public string Reason = ""; public string LogPath = ""; public long OriginalBytes; public int CompressedBytes; public byte[] CompressedBytesArray = Array.Empty(); public string Sha256 = ""; public int ChunkSize; public int ChunkCount; public string ClientPlayerName = ""; public string LogModifiedUtc = ""; } private sealed class UploadResult { public bool Success; public string Message = ""; } private static readonly Dictionary Results = new Dictionary(); private static readonly HashSet ActiveReasons = new HashSet(); public static void StartUpload(string reason, string requestId, int timeoutSeconds, Action? continueAfter) { DiscordToolsPlugin instance = DiscordToolsPlugin.Instance; if ((Object)(object)instance == (Object)null) { continueAfter?.Invoke(); return; } if (!ShouldUpload()) { continueAfter?.Invoke(); return; } if (ActiveReasons.Contains(reason)) { continueAfter?.Invoke(); return; } ActiveReasons.Add(reason); ((MonoBehaviour)instance).StartCoroutine(UploadRoutine(reason, requestId, timeoutSeconds, continueAfter)); } public static void OnResult(long sender, ZPackage pkg) { string key = pkg.ReadString(); Results[key] = new UploadResult { Success = pkg.ReadBool(), Message = pkg.ReadString() }; } public static bool ShouldUpload() { //IL_0020: Unknown result type (might be due to invalid IL or missing references) //IL_0026: Invalid comparison between Unknown and I4 if ((Object)(object)ZNet.instance != (Object)null && ZRoutedRpc.instance != null && !ZNet.instance.IsServer()) { return (int)ZNet.GetConnectionStatus() == 2; } return false; } private static IEnumerator UploadRoutine(string reason, string requestId, int timeoutSeconds, Action? continueAfter) { string reason2 = reason; string requestId2 = requestId; PreparedLog prepared = null; Exception error = null; Task prepareTask = Task.Run(() => PrepareLog(reason2, requestId2)); while (!prepareTask.IsCompleted) { yield return null; } if (prepareTask.IsFaulted) { error = prepareTask.Exception?.GetBaseException(); } else { prepared = prepareTask.Result; } if (error != null || prepared == null) { DiscordToolsPlugin.Log.LogWarning((object)("Could not prepare client log: " + (error?.Message ?? "unknown error"))); ActiveReasons.Remove(reason2); continueAfter?.Invoke(); yield break; } if (prepared.OriginalBytes > DiscordToolsPlugin.MaxOriginalBytes.Value) { DiscordToolsPlugin.Log.LogWarning((object)"Client log is larger than MaxOriginalBytes. Upload skipped."); ActiveReasons.Remove(reason2); continueAfter?.Invoke(); yield break; } if (prepared.CompressedBytes > DiscordToolsPlugin.MaxCompressedBytes.Value) { DiscordToolsPlugin.Log.LogWarning((object)"Compressed client log is larger than MaxCompressedBytes. Upload skipped."); ActiveReasons.Remove(reason2); continueAfter?.Invoke(); yield break; } SendMetadata(prepared); for (int i = 0; i < prepared.ChunkCount; i++) { int num = i * prepared.ChunkSize; int num2 = Math.Min(prepared.ChunkSize, prepared.CompressedBytes - num); byte[] array = new byte[num2]; Buffer.BlockCopy(prepared.CompressedBytesArray, num, array, 0, num2); ZPackage val = new ZPackage(); val.Write(prepared.RequestId); val.Write(i); val.Write(array); ZRoutedRpc.instance.InvokeRoutedRPC("DiscordTools_LogChunk", new object[1] { val }); if (i % 8 == 7) { yield return null; } } float deadline = Time.realtimeSinceStartup + (float)Math.Max(1, timeoutSeconds); while (!Results.ContainsKey(prepared.RequestId) && Time.realtimeSinceStartup < deadline) { yield return null; } if (Results.TryGetValue(prepared.RequestId, out UploadResult value)) { DiscordToolsPlugin.Log.LogInfo((object)("Client log upload result: " + value.Message)); Results.Remove(prepared.RequestId); } else { DiscordToolsPlugin.Log.LogWarning((object)"Client log upload timed out waiting for server acknowledgement."); } ActiveReasons.Remove(reason2); continueAfter?.Invoke(); } private static PreparedLog PrepareLog(string reason, string requestId) { string text = Path.Combine(Paths.BepInExRootPath, "LogOutput.log"); if (!File.Exists(text)) { string fullPath = Path.GetFullPath(Path.Combine(Paths.BepInExRootPath, "..", "LogOutput.log")); if (File.Exists(fullPath)) { text = fullPath; } } if (!File.Exists(text)) { throw new FileNotFoundException("Could not find LogOutput.log.", text); } byte[] array; using (FileStream fileStream = new FileStream(text, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) { using MemoryStream memoryStream = new MemoryStream(); fileStream.CopyTo(memoryStream); array = memoryStream.ToArray(); } byte[] array2; using (MemoryStream memoryStream2 = new MemoryStream()) { using (GZipStream gZipStream = new GZipStream(memoryStream2, CompressionLevel.Optimal, leaveOpen: true)) { gZipStream.Write(array, 0, array.Length); } array2 = memoryStream2.ToArray(); } int num = Mathf.Clamp(DiscordToolsPlugin.ChunkSizeBytes.Value, 4096, 262144); FileInfo fileInfo = new FileInfo(text); return new PreparedLog { RequestId = requestId, Reason = reason, LogPath = text, OriginalBytes = array.Length, CompressedBytes = array2.Length, CompressedBytesArray = array2, Sha256 = Sha256Hex(array2), ChunkSize = num, ChunkCount = (int)Math.Ceiling((double)array2.Length / (double)num), ClientPlayerName = GetLocalPlayerName(), LogModifiedUtc = fileInfo.LastWriteTimeUtc.ToString("O", CultureInfo.InvariantCulture) }; } private static void SendMetadata(PreparedLog prepared) { //IL_0000: Unknown result type (might be due to invalid IL or missing references) //IL_0006: Expected O, but got Unknown ZPackage val = new ZPackage(); val.Write(prepared.RequestId); val.Write(prepared.Reason); val.Write(prepared.OriginalBytes); val.Write((long)prepared.CompressedBytes); val.Write(prepared.Sha256); val.Write(prepared.ChunkSize); val.Write(prepared.ChunkCount); val.Write(prepared.ClientPlayerName); val.Write(prepared.LogModifiedUtc); ZRoutedRpc.instance.InvokeRoutedRPC("DiscordTools_LogMeta", new object[1] { val }); } private static string GetLocalPlayerName() { try { return ((Object)(object)Game.instance != (Object)null) ? Game.instance.GetPlayerProfile().GetName() : ""; } catch { return ""; } } private static string Sha256Hex(byte[] bytes) { using SHA256 sHA = SHA256.Create(); return BitConverter.ToString(sHA.ComputeHash(bytes)).Replace("-", "").ToLowerInvariant(); } } internal static class LogArchive { private sealed class MetadataEntry { public string PlayerId = ""; public string PlayerName = ""; public string PlayerFolder = ""; public string Reason = ""; public string Path = ""; public DateTime ReceivedAtUtc; } public static string Root => ResolveRoot(); private static string PlayersDir => Path.Combine(Root, "players"); private static string IndexDir => Path.Combine(Root, "index"); private static string IncomingDir => Path.Combine(Root, "incoming"); private static string BotUploadFailedDir => Path.Combine(Root, "bot-upload-failed"); public static void EnsureDirectories() { Directory.CreateDirectory(PlayersDir); Directory.CreateDirectory(IndexDir); Directory.CreateDirectory(IncomingDir); Directory.CreateDirectory(BotUploadFailedDir); } public static string GetIncomingPath(string requestId) { EnsureDirectories(); return Path.Combine(IncomingDir, SafePathSegment(requestId) + ".tmp"); } public static ArchivedLog Archive(IncomingTransfer transfer) { EnsureDirectories(); string playerId = SafePathSegment(PlayerResolver.StablePlayerId(transfer.Peer)); string text = (string.IsNullOrWhiteSpace(transfer.Peer.m_playerName) ? transfer.ClientPlayerName : transfer.Peer.m_playerName); text = (string.IsNullOrWhiteSpace(text) ? "unknown" : text); string text2 = BuildPlayerFolderName(text, playerId); string playerFolder = "players/" + text2; string path = transfer.ReceivedAtUtc.ToString("yyyy-MM", CultureInfo.InvariantCulture); string text3 = transfer.ReceivedAtUtc.ToString("yyyy-MM-dd_HH-mm-ss'Z'", CultureInfo.InvariantCulture); string text4 = text3 + "_" + transfer.Reason + "_" + SafePathSegment(text) + "_LogOutput"; string text5 = Path.Combine(PlayersDir, text2); string text6 = Path.Combine(text5, "logs", path); Directory.CreateDirectory(text6); string text7 = Path.Combine(text6, text4 + ".log.gz"); string text8 = Path.Combine(text6, text4 + ".json"); File.Copy(transfer.TempPath, text7, overwrite: true); ArchivedLog archivedLog = new ArchivedLog { PlayerId = playerId, PlayerName = text, PlayerFolder = playerFolder, Reason = transfer.Reason, RequestId = transfer.RequestId, ReceivedAtUtc = transfer.ReceivedAtUtc, OriginalBytes = transfer.OriginalBytes, CompressedBytes = transfer.CompressedBytes, Sha256 = transfer.Sha256, LogPath = text7, MetadataPath = text8, RelativeLogPath = ToRelative(text7), RelativeMetadataPath = ToRelative(text8) }; File.WriteAllText(text8, BuildLogMetadataJson(archivedLog, transfer), Encoding.UTF8); WritePlayerFiles(text5, archivedLog); RebuildIndexes(); return archivedLog; } public static void CleanupOldLogs() { int value = DiscordToolsPlugin.RetentionDays.Value; if (value <= 0 || !Directory.Exists(PlayersDir)) { return; } DateTime dateTime = DateTime.UtcNow.AddDays(-value); string[] files = Directory.GetFiles(PlayersDir, "*", SearchOption.AllDirectories); foreach (string path in files) { if (IsArchivedLogFile(path) && File.GetLastWriteTimeUtc(path) < dateTime) { TryDelete(path); } } RebuildIndexes(); } public static void MarkBotUploadFailed(ArchivedLog log, string message) { Directory.CreateDirectory(BotUploadFailedDir); File.WriteAllText(Path.Combine(BotUploadFailedDir, Path.GetFileName(log.MetadataPath)), JsonObject(new Dictionary { ["playerId"] = log.PlayerId, ["playerName"] = log.PlayerName, ["playerFolder"] = log.PlayerFolder, ["reason"] = log.Reason, ["receivedAtUtc"] = log.ReceivedAtUtc.ToString("O", CultureInfo.InvariantCulture), ["path"] = log.RelativeLogPath, ["error"] = message }), Encoding.UTF8); } private static void WritePlayerFiles(string playerDir, ArchivedLog latest) { string path = Path.Combine(playerDir, "player.json"); SortedSet sortedSet = new SortedSet(StringComparer.OrdinalIgnoreCase) { latest.PlayerName }; if (File.Exists(path)) { foreach (Match item in Regex.Matches(File.ReadAllText(path), "\"knownNames\"\\s*:\\s*\\[(.*?)\\]", RegexOptions.Singleline)) { foreach (Match item2 in Regex.Matches(item.Groups[1].Value, "\"(.*?)\"")) { sortedSet.Add(UnescapeJson(item2.Groups[1].Value)); } } } File.WriteAllText(path, BuildPlayerJson(latest, sortedSet), Encoding.UTF8); File.WriteAllText(Path.Combine(playerDir, "latest.json"), JsonObject(new Dictionary { ["latestLog"] = latest.RelativeLogPath, ["latestMetadata"] = latest.RelativeMetadataPath, ["receivedAtUtc"] = latest.ReceivedAtUtc.ToString("O", CultureInfo.InvariantCulture), ["reason"] = latest.Reason, ["playerId"] = latest.PlayerId, ["playerFolder"] = latest.PlayerFolder, ["playerName"] = latest.PlayerName }), Encoding.UTF8); } private static void RebuildIndexes() { EnsureDirectories(); List list = (from entry in ReadAllMetadata() orderby entry.ReceivedAtUtc descending select entry).ToList(); SortedDictionary sortedDictionary = new SortedDictionary(StringComparer.OrdinalIgnoreCase); SortedDictionary> sortedDictionary2 = new SortedDictionary>(StringComparer.OrdinalIgnoreCase); SortedDictionary> sortedDictionary3 = new SortedDictionary>(StringComparer.OrdinalIgnoreCase); SortedDictionary> sortedDictionary4 = new SortedDictionary>(StringComparer.OrdinalIgnoreCase); foreach (MetadataEntry item in list) { string value = LegacyPlayerFolder(item.PlayerId); if (!sortedDictionary.TryGetValue(item.PlayerId, out var _) || item.PlayerFolder.Equals(value, StringComparison.OrdinalIgnoreCase)) { sortedDictionary[item.PlayerId] = item.PlayerFolder; } AddToStringSetMap(sortedDictionary3, item.PlayerId, item.PlayerFolder); string text = item.PlayerName.Trim().ToLowerInvariant(); if (text.Length != 0) { AddToStringSetMap(sortedDictionary2, text, item.PlayerId); AddToStringSetMap(sortedDictionary4, text, item.PlayerFolder); } } File.WriteAllText(Path.Combine(IndexDir, "players.json"), BuildPlayersIndexJson(sortedDictionary, sortedDictionary2, sortedDictionary3, sortedDictionary4), Encoding.UTF8); File.WriteAllText(Path.Combine(IndexDir, "recent.json"), BuildRecentJson(list.Take(100).ToList()), Encoding.UTF8); } private static List ReadAllMetadata() { List list = new List(); if (!Directory.Exists(PlayersDir)) { return list; } string[] files = Directory.GetFiles(PlayersDir, "*.json", SearchOption.AllDirectories); foreach (string text in files) { if (Path.GetFileName(text).Equals("player.json", StringComparison.OrdinalIgnoreCase) || Path.GetFileName(text).Equals("latest.json", StringComparison.OrdinalIgnoreCase)) { continue; } try { string json = File.ReadAllText(text); MetadataEntry metadataEntry = new MetadataEntry { PlayerId = JsonValue(json, "playerId"), PlayerName = JsonValue(json, "playerName"), Reason = JsonValue(json, "reason"), Path = JsonValue(json, "logPath") }; metadataEntry.PlayerFolder = JsonValue(json, "playerFolder"); if (string.IsNullOrWhiteSpace(metadataEntry.PlayerFolder)) { metadataEntry.PlayerFolder = PlayerFolderFromRelativeLogPath(metadataEntry.Path, metadataEntry.PlayerId); } if (!DateTime.TryParse(JsonValue(json, "receivedAtUtc"), null, DateTimeStyles.RoundtripKind, out metadataEntry.ReceivedAtUtc)) { metadataEntry.ReceivedAtUtc = File.GetLastWriteTimeUtc(text); } if (!string.IsNullOrWhiteSpace(metadataEntry.PlayerId)) { list.Add(metadataEntry); } } catch (Exception ex) { DiscordToolsPlugin.Log.LogWarning((object)("Could not read metadata " + text + ": " + ex.Message)); } } return list; } private static string ResolveRoot() { string text = DiscordToolsPlugin.OutputDirectory.Value; if (string.IsNullOrWhiteSpace(text)) { text = "client-logs"; } if (!Path.IsPathRooted(text)) { return Path.Combine(Paths.BepInExRootPath, text); } return text; } private static bool IsArchivedLogFile(string path) { string text = path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); char directorySeparatorChar = Path.DirectorySeparatorChar; string text2 = directorySeparatorChar.ToString(); directorySeparatorChar = Path.DirectorySeparatorChar; string value = text2 + "logs" + directorySeparatorChar; if (text.IndexOf(value, StringComparison.OrdinalIgnoreCase) >= 0) { if (!path.EndsWith(".log.gz", StringComparison.OrdinalIgnoreCase)) { return path.EndsWith(".json", StringComparison.OrdinalIgnoreCase); } return true; } return false; } private static string BuildLogMetadataJson(ArchivedLog archived, IncomingTransfer transfer) { return JsonObject(new Dictionary { ["requestId"] = archived.RequestId, ["reason"] = archived.Reason, ["playerId"] = archived.PlayerId, ["playerName"] = archived.PlayerName, ["playerFolder"] = archived.PlayerFolder, ["clientPlayerName"] = transfer.ClientPlayerName, ["receivedAtUtc"] = archived.ReceivedAtUtc.ToString("O", CultureInfo.InvariantCulture), ["logModifiedUtc"] = transfer.LogModifiedUtc, ["originalBytes"] = archived.OriginalBytes.ToString(CultureInfo.InvariantCulture), ["compressedBytes"] = archived.CompressedBytes.ToString(CultureInfo.InvariantCulture), ["compression"] = "gzip", ["sha256"] = archived.Sha256, ["logPath"] = archived.RelativeLogPath, ["metadataPath"] = archived.RelativeMetadataPath }); } private static string BuildPlayerJson(ArchivedLog latest, IEnumerable knownNames) { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append("{\n"); AppendJsonProperty(stringBuilder, "playerId", latest.PlayerId, comma: true); AppendJsonProperty(stringBuilder, "lastKnownName", latest.PlayerName, comma: true); AppendJsonProperty(stringBuilder, "playerFolder", latest.PlayerFolder, comma: true); stringBuilder.Append(" \"knownNames\": ["); stringBuilder.Append(string.Join(", ", knownNames.Select((string name) => "\"" + EscapeJson(name) + "\"").ToArray())); stringBuilder.Append("],\n"); AppendJsonProperty(stringBuilder, "lastSeenUtc", latest.ReceivedAtUtc.ToString("O", CultureInfo.InvariantCulture), comma: false); stringBuilder.Append("}\n"); return stringBuilder.ToString(); } private static string BuildPlayersIndexJson(SortedDictionary byId, SortedDictionary> byName, SortedDictionary> byIdFolders, SortedDictionary> byNameFolders) { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append("{\n \"byId\": {\n"); AppendStringMap(stringBuilder, byId, 4); stringBuilder.Append("\n },\n \"byName\": {\n"); AppendStringSetMap(stringBuilder, byName, 4); stringBuilder.Append(" },\n \"byIdFolders\": {\n"); AppendStringSetMap(stringBuilder, byIdFolders, 4); stringBuilder.Append(" },\n \"byNameFolders\": {\n"); AppendStringSetMap(stringBuilder, byNameFolders, 4); stringBuilder.Append(" }\n}\n"); return stringBuilder.ToString(); } private static string BuildRecentJson(List entries) { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append("[\n"); for (int i = 0; i < entries.Count; i++) { MetadataEntry metadataEntry = entries[i]; stringBuilder.Append(" {\n"); AppendJsonProperty(stringBuilder, "playerId", metadataEntry.PlayerId, comma: true, 4); AppendJsonProperty(stringBuilder, "playerName", metadataEntry.PlayerName, comma: true, 4); AppendJsonProperty(stringBuilder, "playerFolder", metadataEntry.PlayerFolder, comma: true, 4); AppendJsonProperty(stringBuilder, "reason", metadataEntry.Reason, comma: true, 4); AppendJsonProperty(stringBuilder, "receivedAtUtc", metadataEntry.ReceivedAtUtc.ToString("O", CultureInfo.InvariantCulture), comma: true, 4); AppendJsonProperty(stringBuilder, "path", metadataEntry.Path, comma: false, 4); stringBuilder.Append(" }"); if (i + 1 < entries.Count) { stringBuilder.Append(","); } stringBuilder.Append("\n"); } stringBuilder.Append("]\n"); return stringBuilder.ToString(); } private static string JsonObject(Dictionary values) { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append("{\n"); int num = 0; foreach (KeyValuePair value in values) { AppendJsonProperty(stringBuilder, value.Key, value.Value, ++num < values.Count); } stringBuilder.Append("}\n"); return stringBuilder.ToString(); } private static void AddToStringSetMap(SortedDictionary> map, string key, string value) { if (!map.TryGetValue(key, out SortedSet value2)) { value2 = (map[key] = new SortedSet(StringComparer.OrdinalIgnoreCase)); } value2.Add(value); } private static void AppendStringMap(StringBuilder builder, SortedDictionary map, int indent) { string value = new string(' ', indent); int num = 0; foreach (KeyValuePair item in map) { builder.Append(value).Append("\"").Append(EscapeJson(item.Key)) .Append("\": \"") .Append(EscapeJson(item.Value)) .Append("\""); if (++num < map.Count) { builder.Append(","); } builder.Append("\n"); } } private static void AppendStringSetMap(StringBuilder builder, SortedDictionary> map, int indent) { string value2 = new string(' ', indent); int num = 0; foreach (KeyValuePair> item in map) { builder.Append(value2).Append("\"").Append(EscapeJson(item.Key)) .Append("\": ["); builder.Append(string.Join(", ", item.Value.Select((string value) => "\"" + EscapeJson(value) + "\"").ToArray())); builder.Append("]"); if (++num < map.Count) { builder.Append(","); } builder.Append("\n"); } } private static void AppendJsonProperty(StringBuilder builder, string key, string value, bool comma, int indent = 2) { builder.Append(new string(' ', indent)).Append("\"").Append(EscapeJson(key)) .Append("\": ") .Append("\"") .Append(EscapeJson(value)) .Append("\""); if (comma) { builder.Append(","); } builder.Append("\n"); } private static string JsonValue(string json, string key) { Match match = Regex.Match(json, "\"" + Regex.Escape(key) + "\"\\s*:\\s*\"((?:\\\\.|[^\"])*)\""); if (!match.Success) { return ""; } return UnescapeJson(match.Groups[1].Value); } private static string PlayerFolderFromRelativeLogPath(string relativeLogPath, string playerId) { string text = (relativeLogPath ?? "").Replace('\\', '/'); int num = text.IndexOf("/logs/", StringComparison.OrdinalIgnoreCase); if (text.StartsWith("players/", StringComparison.OrdinalIgnoreCase) && num > "players/".Length) { return text.Substring(0, num); } return LegacyPlayerFolder(playerId); } private static string EscapeJson(string value) { return (value ?? "").Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\r", "\\r") .Replace("\n", "\\n"); } private static string UnescapeJson(string value) { return (value ?? "").Replace("\\n", "\n").Replace("\\r", "\r").Replace("\\\"", "\"") .Replace("\\\\", "\\"); } private static string SafePathSegment(string value) { value = (string.IsNullOrWhiteSpace(value) ? "unknown" : value.Trim()); char[] invalid = Path.GetInvalidFileNameChars(); string input = new string(value.Select((char ch) => (!invalid.Contains(ch) && !char.IsWhiteSpace(ch)) ? ch : '_').ToArray()); input = Regex.Replace(input, "_+", "_").Trim(new char[1] { '_' }); if (input.Length != 0) { return input; } return "unknown"; } private static string BuildPlayerFolderName(string playerName, string playerId) { return SafePathSegment(playerName) + "_" + SafePathSegment(playerId); } private static string LegacyPlayerFolder(string playerId) { return "players/" + SafePathSegment(playerId); } private static string ToRelative(string path) { string text = Root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); char directorySeparatorChar = Path.DirectorySeparatorChar; string text2 = text + directorySeparatorChar; if (!path.StartsWith(text2, StringComparison.OrdinalIgnoreCase)) { return path; } return path.Substring(text2.Length).Replace(Path.DirectorySeparatorChar, '/'); } private static void TryDelete(string path) { try { if (File.Exists(path)) { File.Delete(path); } } catch { } } } internal sealed class IncomingTransfer { public long Sender; public ZNetPeer Peer; public string RequestId = ""; public string Reason = ""; public long OriginalBytes; public long CompressedBytes; public string Sha256 = ""; public int ChunkSize; public int ChunkCount; public string ClientPlayerName = ""; public string LogModifiedUtc = ""; public DateTime ReceivedAtUtc; public string TempPath = ""; public bool[] ReceivedChunks = Array.Empty(); public int ReceivedCount; } internal sealed class ArchivedLog { public string PlayerId = ""; public string PlayerName = ""; public string PlayerFolder = ""; public string Reason = ""; public string RequestId = ""; public DateTime ReceivedAtUtc; public long OriginalBytes; public long CompressedBytes; public string Sha256 = ""; public string LogPath = ""; public string MetadataPath = ""; public string RelativeLogPath = ""; public string RelativeMetadataPath = ""; } [HarmonyPatch(typeof(ZNet), "Awake")] internal static class ZNetAwakePatch { private static void Postfix() { ClientLogRpc.Register(); } } [HarmonyPatch(typeof(Game), "Logout")] internal static class GameLogoutPatch { private static bool _continuing; private static bool Prefix(Game __instance, bool save, bool changeToStartScene) { Game __instance2 = __instance; if (_continuing || !ClientLogUploader.ShouldUpload()) { return true; } ClientLogUploader.StartUpload("logout", Guid.NewGuid().ToString("N"), DiscordToolsPlugin.LogoutUploadTimeoutSeconds.Value, delegate { _continuing = true; try { __instance2.Logout(save, changeToStartScene); } finally { _continuing = false; } }); return false; } } [HarmonyPatch(typeof(Menu), "QuitGame")] internal static class MenuQuitPatch { private static bool _continuing; private static bool Prefix() { if (_continuing || !ClientLogUploader.ShouldUpload()) { return true; } ClientLogUploader.StartUpload("quit", Guid.NewGuid().ToString("N"), DiscordToolsPlugin.QuitUploadTimeoutSeconds.Value, delegate { _continuing = true; try { Gogan.LogEvent("Game", "Quit", "", 0L); Application.Quit(); } finally { _continuing = false; } }); return false; } } internal static class PlayerResolver { public static List FindPeers(string query) { List list = new List(); if ((Object)(object)ZNet.instance == (Object)null) { return list; } string text = Normalize(query); foreach (ZNetPeer connectedPeer in ZNet.instance.GetConnectedPeers()) { if (connectedPeer.IsReady()) { string value = SafeHostName(connectedPeer); string value2 = StablePlayerId(connectedPeer); if (Normalize(connectedPeer.m_playerName) == text || Normalize(value) == text || Normalize(value2) == text || (DigitsOnly(value) == DigitsOnly(query) && DigitsOnly(query).Length > 0)) { list.Add(connectedPeer); } } } if (list.Count > 0) { return list; } foreach (ZNetPeer connectedPeer2 in ZNet.instance.GetConnectedPeers()) { if (connectedPeer2.IsReady() && Normalize(connectedPeer2.m_playerName).Contains(text)) { list.Add(connectedPeer2); } } return list; } public static ZNetPeer? FindPeerBySender(long sender) { if ((Object)(object)ZNet.instance == (Object)null) { return null; } return ((IEnumerable)ZNet.instance.GetConnectedPeers()).FirstOrDefault((Func)((ZNetPeer peer) => peer.m_uid == sender)); } public static string DescribePeer(ZNetPeer peer) { return peer.m_playerName + " (" + StablePlayerId(peer) + ")"; } public static string StablePlayerId(ZNetPeer peer) { string text = SafeHostName(peer); if (!string.IsNullOrWhiteSpace(text)) { return text; } return peer.m_uid.ToString(CultureInfo.InvariantCulture); } public static string SafeHostName(ZNetPeer peer) { try { ISocket socket = peer.m_socket; return ((socket != null) ? socket.GetHostName() : null) ?? ""; } catch { return ""; } } public static string SafeEndPoint(ZNetPeer peer) { try { ISocket socket = peer.m_socket; return ((socket != null) ? socket.GetEndPointString() : null) ?? ""; } catch { return ""; } } public static string PlatformDisplayName(ZNetPeer peer) { try { string value; return (peer.m_serverSyncedPlayerData != null && peer.m_serverSyncedPlayerData.TryGetValue("platformDisplayName", out value)) ? value : ""; } catch { return ""; } } private static string Normalize(string value) { return (value ?? "").Trim().ToLowerInvariant(); } private static string DigitsOnly(string value) { return new string((value ?? "").Where(char.IsDigit).ToArray()); } } [BepInPlugin("warpalicious.DiscordTools", "DiscordTools", "1.4.0")] public class DiscordToolsPlugin : BaseUnityPlugin { private const string ModName = "DiscordTools"; private const string ModVersion = "1.4.0"; private const string Author = "warpalicious"; private const string ModGUID = "warpalicious.DiscordTools"; private const string BotApiUrlEnv = "DISCORDTOOLS_BOT_API_URL"; private const string BotApiKeyEnv = "DISCORDTOOLS_BOT_API_KEY"; private readonly Harmony _harmony = new Harmony("warpalicious.DiscordTools"); private DateTime _lastReloadTime; private const long ReloadDelayTicks = 10000000L; public static readonly ManualLogSource Log = Logger.CreateLogSource("DiscordTools"); internal static ConfigEntry CommandName = null; internal static ConfigEntry OutputDirectory = null; internal static ConfigEntry ChunkSizeBytes = null; internal static ConfigEntry ManualRequestTimeoutSeconds = null; internal static ConfigEntry LogoutUploadTimeoutSeconds = null; internal static ConfigEntry QuitUploadTimeoutSeconds = null; internal static ConfigEntry RetentionDays = null; internal static ConfigEntry DeleteOldLogsOnStartup = null; internal static ConfigEntry MaxOriginalBytes = null; internal static ConfigEntry MaxCompressedBytes = null; internal static ConfigEntry PostToBotApi = null; internal static ConfigEntry BotApiUrl = null; internal static ConfigEntry BotApiKey = null; public static DiscordToolsPlugin? Instance { get; private set; } internal static string GetBotApiUrl() { string environmentVariable = Environment.GetEnvironmentVariable("DISCORDTOOLS_BOT_API_URL"); if (!string.IsNullOrWhiteSpace(environmentVariable)) { return environmentVariable.Trim(); } return BotApiUrl.Value; } internal static string GetBotApiKey() { string environmentVariable = Environment.GetEnvironmentVariable("DISCORDTOOLS_BOT_API_KEY"); if (!string.IsNullOrWhiteSpace(environmentVariable)) { return environmentVariable.Trim(); } return BotApiKey.Value; } public void Awake() { Instance = this; BindConfig(); ClientLogCommand.Register(); _harmony.PatchAll(Assembly.GetExecutingAssembly()); SetupWatcher(); LogArchive.EnsureDirectories(); if (DeleteOldLogsOnStartup.Value) { LogArchive.CleanupOldLogs(); } } private void OnDestroy() { ((BaseUnityPlugin)this).Config.Save(); _harmony.UnpatchSelf(); if ((Object)(object)Instance == (Object)(object)this) { Instance = null; } } private void BindConfig() { CommandName = ((BaseUnityPlugin)this).Config.Bind("General", "CommandName", "client-logs", "Server command used by RCON to request a connected client's log."); OutputDirectory = ((BaseUnityPlugin)this).Config.Bind("General", "OutputDirectory", "client-logs", "Log archive directory. Relative paths are placed under BepInEx."); ChunkSizeBytes = ((BaseUnityPlugin)this).Config.Bind("General", "ChunkSizeBytes", 32768, "Compressed upload chunk size sent through Valheim networking."); ManualRequestTimeoutSeconds = ((BaseUnityPlugin)this).Config.Bind("General", "ManualRequestTimeoutSeconds", 120, "How long the client waits for the server to acknowledge a manual log request."); LogoutUploadTimeoutSeconds = ((BaseUnityPlugin)this).Config.Bind("General", "LogoutUploadTimeoutSeconds", 30, "How long logout waits for log upload before continuing."); QuitUploadTimeoutSeconds = ((BaseUnityPlugin)this).Config.Bind("General", "QuitUploadTimeoutSeconds", 10, "How long normal quit waits for log upload before continuing."); RetentionDays = ((BaseUnityPlugin)this).Config.Bind("General", "RetentionDays", 30, "Delete archived logs older than this many days. Set 0 to keep logs forever."); DeleteOldLogsOnStartup = ((BaseUnityPlugin)this).Config.Bind("General", "DeleteOldLogsOnStartup", true, "Run retention cleanup when the mod loads."); MaxOriginalBytes = ((BaseUnityPlugin)this).Config.Bind("Limits", "MaxOriginalBytes", 104857600L, "Largest uncompressed client log accepted, in bytes."); MaxCompressedBytes = ((BaseUnityPlugin)this).Config.Bind("Limits", "MaxCompressedBytes", 52428800L, "Largest compressed client log accepted, in bytes."); PostToBotApi = ((BaseUnityPlugin)this).Config.Bind("BotApi", "PostToBotApi", true, "Upload received logs to a compatible Discord bot API."); BotApiUrl = ((BaseUnityPlugin)this).Config.Bind("BotApi", "ApiUrl", "", "Compatible bot client-log upload endpoint. Prefer the DISCORDTOOLS_BOT_API_URL environment variable on dedicated servers."); BotApiKey = ((BaseUnityPlugin)this).Config.Bind("BotApi", "ApiKey", "", "API key sent to the bot in the X-API-Key header. Prefer the DISCORDTOOLS_BOT_API_KEY environment variable on dedicated servers."); } private void SetupWatcher() { _lastReloadTime = DateTime.Now; FileSystemWatcher fileSystemWatcher = new FileSystemWatcher(Paths.ConfigPath, "warpalicious.DiscordTools.cfg"); fileSystemWatcher.Changed += ReadConfigValues; fileSystemWatcher.Created += ReadConfigValues; fileSystemWatcher.Renamed += ReadConfigValues; fileSystemWatcher.IncludeSubdirectories = true; fileSystemWatcher.EnableRaisingEvents = true; } private void ReadConfigValues(object sender, FileSystemEventArgs e) { DateTime now = DateTime.Now; long num = now.Ticks - _lastReloadTime.Ticks; if (File.Exists(Path.Combine(Paths.ConfigPath, "warpalicious.DiscordTools.cfg")) && num >= 10000000) { try { Log.LogInfo((object)"Reloading configuration."); ((BaseUnityPlugin)this).Config.Reload(); LogArchive.EnsureDirectories(); } catch (Exception ex) { Log.LogError((object)("Failed to reload configuration: " + ex.Message)); } _lastReloadTime = now; } } } internal static class RpcNames { public const string RequestLog = "DiscordTools_RequestLog"; public const string LogMeta = "DiscordTools_LogMeta"; public const string LogChunk = "DiscordTools_LogChunk"; public const string LogResult = "DiscordTools_LogResult"; } internal static class ServerLogReceiver { private static readonly Dictionary Transfers = new Dictionary(); public static void OnMetadata(long sender, ZPackage pkg) { if ((Object)(object)ZNet.instance == (Object)null || !ZNet.instance.IsServer()) { return; } ZNetPeer val = PlayerResolver.FindPeerBySender(sender); if (val != null) { IncomingTransfer incomingTransfer = new IncomingTransfer { Sender = sender, Peer = val, RequestId = pkg.ReadString(), Reason = SafeReason(pkg.ReadString()), OriginalBytes = pkg.ReadLong(), CompressedBytes = pkg.ReadLong(), Sha256 = pkg.ReadString(), ChunkSize = pkg.ReadInt(), ChunkCount = pkg.ReadInt(), ClientPlayerName = pkg.ReadString(), LogModifiedUtc = pkg.ReadString(), ReceivedAtUtc = DateTime.UtcNow }; if (incomingTransfer.OriginalBytes > DiscordToolsPlugin.MaxOriginalBytes.Value) { SendResult(sender, incomingTransfer.RequestId, success: false, "Original log exceeds server limit."); return; } if (incomingTransfer.CompressedBytes > DiscordToolsPlugin.MaxCompressedBytes.Value) { SendResult(sender, incomingTransfer.RequestId, success: false, "Compressed log exceeds server limit."); return; } if (incomingTransfer.ChunkSize <= 0 || incomingTransfer.ChunkCount <= 0 || incomingTransfer.ChunkCount > 100000) { SendResult(sender, incomingTransfer.RequestId, success: false, "Invalid upload metadata."); return; } incomingTransfer.TempPath = LogArchive.GetIncomingPath(incomingTransfer.RequestId); incomingTransfer.ReceivedChunks = new bool[incomingTransfer.ChunkCount]; Transfers[incomingTransfer.RequestId] = incomingTransfer; DiscordToolsPlugin.Log.LogInfo((object)("Receiving client log from " + PlayerResolver.DescribePeer(val) + " reason=" + incomingTransfer.Reason + " request=" + incomingTransfer.RequestId)); } } public static void OnChunk(long sender, ZPackage pkg) { if ((Object)(object)ZNet.instance == (Object)null || !ZNet.instance.IsServer()) { return; } string key = pkg.ReadString(); if (!Transfers.TryGetValue(key, out IncomingTransfer value) || value.Sender != sender) { return; } int num = pkg.ReadInt(); byte[] array = pkg.ReadByteArray(); if (num >= 0 && num < value.ChunkCount && !value.ReceivedChunks[num]) { Directory.CreateDirectory(Path.GetDirectoryName(value.TempPath)); using (FileStream fileStream = new FileStream(value.TempPath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None)) { fileStream.Seek((long)num * (long)value.ChunkSize, SeekOrigin.Begin); fileStream.Write(array, 0, array.Length); } value.ReceivedChunks[num] = true; value.ReceivedCount++; if (value.ReceivedCount >= value.ChunkCount) { FinishTransfer(value); Transfers.Remove(key); } } } private static void FinishTransfer(IncomingTransfer transfer) { try { FileInfo fileInfo = new FileInfo(transfer.TempPath); if (!fileInfo.Exists || fileInfo.Length != transfer.CompressedBytes) { SendResult(transfer.Sender, transfer.RequestId, success: false, "Compressed byte count did not match."); return; } if (!string.Equals(Sha256Hex(File.ReadAllBytes(transfer.TempPath)), transfer.Sha256, StringComparison.OrdinalIgnoreCase)) { SendResult(transfer.Sender, transfer.RequestId, success: false, "Compressed file hash did not match."); return; } ArchivedLog archivedLog = LogArchive.Archive(transfer); SendResult(transfer.Sender, transfer.RequestId, success: true, "Saved client log to " + archivedLog.RelativeLogPath); DiscordToolsPlugin instance = DiscordToolsPlugin.Instance; if ((Object)(object)instance != (Object)null && DiscordToolsPlugin.PostToBotApi.Value) { ((MonoBehaviour)instance).StartCoroutine(BotApiClient.PostLogRoutine(archivedLog)); } } catch (Exception ex) { DiscordToolsPlugin.Log.LogError((object)("Failed to finish client log transfer: " + ex)); SendResult(transfer.Sender, transfer.RequestId, success: false, "Server failed to archive log: " + ex.Message); } finally { TryDelete(transfer.TempPath); } } private static void SendResult(long target, string requestId, bool success, string message) { //IL_0000: Unknown result type (might be due to invalid IL or missing references) //IL_0006: Expected O, but got Unknown ZPackage val = new ZPackage(); val.Write(requestId); val.Write(success); val.Write(message); ZRoutedRpc.instance.InvokeRoutedRPC(target, "DiscordTools_LogResult", new object[1] { val }); } private static string Sha256Hex(byte[] bytes) { using SHA256 sHA = SHA256.Create(); return BitConverter.ToString(sHA.ComputeHash(bytes)).Replace("-", "").ToLowerInvariant(); } private static string SafeReason(string reason) { reason = (reason ?? "").Trim().ToLowerInvariant(); switch (reason) { default: return "unknown"; case "logout": case "quit": case "manual": return reason; } } private static void TryDelete(string path) { try { if (File.Exists(path)) { File.Delete(path); } } catch { } } } }