using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Net.Http; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Security; using System.Security.Permissions; using System.Text; using System.Text.RegularExpressions; using System.Threading; using BoneLib.BoneMenu; using FusionPresence; using FusionPresence.Config; using FusionPresence.Menu; using FusionPresence.Net; using FusionPresence.Runtime; using LabFusion.Data; using LabFusion.Network; using LabFusion.Player; using LabFusion.Representation; using LabFusion.SDK.Metadata; using LabFusion.Scene; using LabFusion.Utilities; using MelonLoader; using MelonLoader.Preferences; using Microsoft.CodeAnalysis; using UnityEngine; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)] [assembly: MelonInfo(typeof(Main), "FusionPresence", "1.0.1", "Quartz", null)] [assembly: MelonGame("Stress Level Zero", "BONELAB")] [assembly: MelonAdditionalDependencies(new string[] { "BoneLib" })] [assembly: MelonOptionalDependencies(new string[] { "LabFusion" })] [assembly: TargetFramework(".NETCoreApp,Version=v6.0", FrameworkDisplayName = ".NET 6.0")] [assembly: AssemblyCompany("Quartz")] [assembly: AssemblyConfiguration("Release")] [assembly: AssemblyFileVersion("1.0.1.0")] [assembly: AssemblyInformationalVersion("1.0.1")] [assembly: AssemblyProduct("FusionPresence")] [assembly: AssemblyTitle("FusionPresence")] [assembly: SecurityPermission(SecurityAction.RequestMinimum, SkipVerification = true)] [assembly: AssemblyVersion("1.0.1.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 FusionPresence { internal static class Log { private static Instance _logger; public static bool Verbose { get; set; } public static void Bind(Instance logger) { _logger = logger; } public static void Msg(string message) { Instance logger = _logger; if (logger != null) { logger.Msg(message); } } public static void Warn(string message) { Instance logger = _logger; if (logger != null) { logger.Warning(message); } } public static void Error(string message) { Instance logger = _logger; if (logger != null) { logger.Error(message); } } public static void Debug(string message) { if (Verbose) { Instance logger = _logger; if (logger != null) { logger.Msg("[debug] " + message); } } } } public class Main : MelonMod { private bool _hooked; public override void OnInitializeMelon() { Log.Bind(((MelonBase)this).LoggerInstance); Settings.Register(); WebhookSender.Start(); PresenceMenu.Create(); if (FusionBridge.IsFusionPresent()) { PresenceTracker.Start(); _hooked = true; Log.Msg("Ready. Configure the webhook under BoneMenu → Fusion Presence."); } else { Log.Warn("LabFusion not found. Presence reporting is idle until it loads."); } } public override void OnSceneWasLoaded(int buildIndex, string sceneName) { if (!_hooked && FusionBridge.IsFusionPresent()) { PresenceTracker.Start(); _hooked = true; Log.Msg("LabFusion detected on scene load, presence reporting is now active."); } } public override void OnUpdate() { if (_hooked) { PresenceTracker.Tick(Time.unscaledDeltaTime); } } public override void OnApplicationQuit() { Shutdown(); } public override void OnDeinitializeMelon() { Shutdown(); } private void Shutdown() { if (_hooked) { PresenceTracker.Stop(); _hooked = false; } WebhookSender.Stop(); PresenceMenu.Destroy(); } } } namespace FusionPresence.Runtime { internal static class FusionBridge { private static ServerEvent _onJoined; private static ServerEvent _onStarted; private static ServerEvent _onDisconnected; private static PlayerUpdate _onPlayerJoined; private static PlayerUpdate _onPlayerLeft; private static UpdateEvent _onSceneInit; public static bool Subscribed { get; private set; } [MethodImpl(MethodImplOptions.NoInlining)] public static bool IsFusionPresent() { try { _ = NetworkInfo.HasServer; return true; } catch (Exception ex) { Log.Debug("Fusion not present: " + ex.GetType().Name); return false; } } [MethodImpl(MethodImplOptions.NoInlining)] public static void Subscribe(Action onJoined, Action onStarted, Action onDisconnected, Action onPlayerJoined, Action onPlayerLeft, Action onSceneInit) { //IL_0042: Unknown result type (might be due to invalid IL or missing references) //IL_004c: Expected O, but got Unknown //IL_0053: Unknown result type (might be due to invalid IL or missing references) //IL_005d: Expected O, but got Unknown //IL_0064: Unknown result type (might be due to invalid IL or missing references) //IL_006e: Expected O, but got Unknown //IL_0075: Unknown result type (might be due to invalid IL or missing references) //IL_007f: Expected O, but got Unknown //IL_0086: Unknown result type (might be due to invalid IL or missing references) //IL_0090: Expected O, but got Unknown //IL_0097: Unknown result type (might be due to invalid IL or missing references) //IL_00a1: Expected O, but got Unknown if (Subscribed) { return; } try { _onJoined = (ServerEvent)delegate { onJoined(); }; _onStarted = (ServerEvent)delegate { onStarted(); }; _onDisconnected = (ServerEvent)delegate { onDisconnected(); }; _onPlayerJoined = (PlayerUpdate)delegate(PlayerID id) { onPlayerJoined(SafeName(id)); }; _onPlayerLeft = (PlayerUpdate)delegate(PlayerID id) { onPlayerLeft(SafeName(id)); }; _onSceneInit = (UpdateEvent)delegate { onSceneInit(); }; MultiplayerHooking.OnJoinedServer += _onJoined; MultiplayerHooking.OnStartedServer += _onStarted; MultiplayerHooking.OnDisconnected += _onDisconnected; MultiplayerHooking.OnPlayerJoined += _onPlayerJoined; MultiplayerHooking.OnPlayerLeft += _onPlayerLeft; MultiplayerHooking.OnMainSceneInitialized += _onSceneInit; Subscribed = true; Log.Msg("Hooked into Fusion multiplayer events."); } catch (Exception value) { Log.Error($"Failed to hook Fusion events: {value}"); } } [MethodImpl(MethodImplOptions.NoInlining)] public static void Unsubscribe() { if (!Subscribed) { return; } try { MultiplayerHooking.OnJoinedServer -= _onJoined; MultiplayerHooking.OnStartedServer -= _onStarted; MultiplayerHooking.OnDisconnected -= _onDisconnected; MultiplayerHooking.OnPlayerJoined -= _onPlayerJoined; MultiplayerHooking.OnPlayerLeft -= _onPlayerLeft; MultiplayerHooking.OnMainSceneInitialized -= _onSceneInit; } catch (Exception value) { Log.Error($"Failed to unhook Fusion events: {value}"); } finally { _onJoined = null; _onStarted = null; _onDisconnected = null; _onPlayerJoined = null; _onPlayerLeft = null; _onSceneInit = null; Subscribed = false; } } [MethodImpl(MethodImplOptions.NoInlining)] public static bool HasServer() { try { return NetworkInfo.HasServer; } catch { return false; } } [MethodImpl(MethodImplOptions.NoInlining)] public static bool LobbyInfoReady() { try { LobbyInfo lobbyInfo = LobbyInfoManager.LobbyInfo; return lobbyInfo != null && lobbyInfo.LobbyID != 0; } catch { return false; } } [MethodImpl(MethodImplOptions.NoInlining)] private static string SafeName(PlayerID id) { try { object obj; if (id == null) { obj = null; } else { PlayerMetadata metadata = id.Metadata; if (metadata == null) { obj = null; } else { MetadataVariable username = metadata.Username; obj = ((username != null) ? username.GetValue() : null); } } string text = (string)obj; if (!string.IsNullOrEmpty(text)) { return text; } object obj2; if (id == null) { obj2 = null; } else { PlayerMetadata metadata2 = id.Metadata; if (metadata2 == null) { obj2 = null; } else { MetadataVariable nickname = metadata2.Nickname; obj2 = ((nickname != null) ? nickname.GetValue() : null); } } string text2 = (string)obj2; if (!string.IsNullOrEmpty(text2)) { return text2; } return (id != null) ? id.PlatformID.ToString() : "unknown"; } catch { return "unknown"; } } [MethodImpl(MethodImplOptions.NoInlining)] public static int GetPlayerCount() { try { return PlayerIDManager.PlayerCount; } catch { return 0; } } [MethodImpl(MethodImplOptions.NoInlining)] public unsafe static SessionSnapshot BuildSnapshot(PresenceEvent kind, string changeSummary = "") { //IL_00a4: Unknown result type (might be due to invalid IL or missing references) //IL_00a9: Unknown result type (might be due to invalid IL or missing references) SessionSnapshot sessionSnapshot = new SessionSnapshot { Event = kind, ChangeSummary = (changeSummary ?? "") }; try { if (Settings.IncludeOwnName.Value) { sessionSnapshot.LocalName = LocalPlayer.Username ?? ""; } sessionSnapshot.IsHost = NetworkInfo.IsHost; } catch (Exception ex) { Log.Debug("local player read failed: " + ex.Message); } try { LobbyInfo lobbyInfo = LobbyInfoManager.LobbyInfo; if (lobbyInfo != null) { sessionSnapshot.LobbyName = lobbyInfo.LobbyName ?? ""; sessionSnapshot.LobbyHostName = lobbyInfo.LobbyHostName ?? ""; sessionSnapshot.MaxPlayers = lobbyInfo.MaxPlayers; sessionSnapshot.Privacy = ((object)lobbyInfo.Privacy/*cast due to .constrained prefix*/).ToString(); sessionSnapshot.LevelTitle = lobbyInfo.LevelTitle ?? ""; sessionSnapshot.LevelBarcode = lobbyInfo.LevelBarcode ?? ""; } } catch (Exception ex2) { Log.Debug("lobby info read failed: " + ex2.Message); } try { string title = FusionSceneManager.Title; if (!string.IsNullOrEmpty(title)) { sessionSnapshot.LevelTitle = title; sessionSnapshot.LevelBarcode = FusionSceneManager.Barcode ?? sessionSnapshot.LevelBarcode; } } catch (Exception ex3) { Log.Debug("scene read failed: " + ex3.Message); } if (Settings.IncludeServerCode.Value) { try { sessionSnapshot.LobbyCode = NetworkHelper.GetServerCode() ?? ""; } catch (Exception ex4) { Log.Debug("server code read failed: " + ex4.Message); } } try { sessionSnapshot.PlayerCount = PlayerIDManager.PlayerCount; if (Settings.IncludePlayerList.Value) { PermissionLevel val = default(PermissionLevel); foreach (PlayerID playerID in PlayerIDManager.PlayerIDs) { if (playerID != null && playerID.IsValid) { SessionSnapshot.PlayerEntry playerEntry = new SessionSnapshot.PlayerEntry(); PlayerMetadata metadata = playerID.Metadata; object obj; if (metadata == null) { obj = null; } else { MetadataVariable username = metadata.Username; obj = ((username != null) ? username.GetValue() : null); } if (obj == null) { obj = ""; } playerEntry.Username = (string)obj; PlayerMetadata metadata2 = playerID.Metadata; object obj2; if (metadata2 == null) { obj2 = null; } else { MetadataVariable nickname = metadata2.Nickname; obj2 = ((nickname != null) ? nickname.GetValue() : null); } if (obj2 == null) { obj2 = ""; } playerEntry.Nickname = (string)obj2; playerEntry.IsHost = playerID.IsHost; playerEntry.IsMe = playerID.IsMe; SessionSnapshot.PlayerEntry playerEntry2 = playerEntry; if (Settings.IncludePermissions.Value && MetadataHelper.TryGetPermissionLevel(playerID, ref val)) { playerEntry2.Permission = ((object)(*(PermissionLevel*)(&val))/*cast due to .constrained prefix*/).ToString(); } sessionSnapshot.Players.Add(playerEntry2); } } } } catch (Exception ex5) { Log.Debug("roster read failed: " + ex5.Message); } return sessionSnapshot; } } internal static class PresenceTracker { private static bool _inSession; private static float _joinTimer = -1f; private static float _joinElapsed; private static PresenceEvent _pendingJoinKind; private static float _rosterTimer = -1f; private static readonly List _joinedSince = new List(); private static readonly List _leftSince = new List(); private const float JoinHardTimeout = 15f; public static void Start() { FusionBridge.Subscribe(delegate { BeginSession(PresenceEvent.Joined); }, delegate { BeginSession(PresenceEvent.Hosting); }, OnDisconnected, OnPlayerJoined, OnPlayerLeft, OnSceneInitialized); } public static void Stop() { FusionBridge.Unsubscribe(); Reset(); } public static void Tick(float deltaTime) { if (_joinTimer >= 0f) { _joinTimer -= deltaTime; _joinElapsed += deltaTime; bool num = FusionBridge.LobbyInfoReady() && _joinTimer <= 0f; bool flag = _joinElapsed >= 15f; if (num || flag) { _joinTimer = -1f; if (flag && !FusionBridge.LobbyInfoReady()) { Log.Warn("Lobby info never arrived; posting with whatever we could read."); } Dispatch(_pendingJoinKind); } else if (_joinTimer <= 0f) { _joinTimer = 0f; } } if (_rosterTimer >= 0f) { _rosterTimer -= deltaTime; if (_rosterTimer <= 0f) { _rosterTimer = -1f; FlushRoster(); } } } private static void BeginSession(PresenceEvent kind) { _inSession = true; _rosterTimer = -1f; _joinedSince.Clear(); _leftSince.Clear(); if (!((kind == PresenceEvent.Hosting) ? Settings.ReportHost.Value : Settings.ReportJoin.Value)) { Log.Debug($"{kind} reporting is disabled."); return; } _pendingJoinKind = kind; _joinElapsed = 0f; _joinTimer = Math.Max(0f, Settings.JoinDelay.Value); Log.Debug($"{kind} detected, posting in up to {_joinTimer:0.#}s."); } private static void OnDisconnected() { _joinTimer = -1f; _rosterTimer = -1f; bool inSession = _inSession; _inSession = false; if (inSession && Settings.ReportLeave.Value) { Dispatch(PresenceEvent.Left); } _joinedSince.Clear(); _leftSince.Clear(); } private static void OnPlayerJoined(string name) { if (_inSession && Settings.ReportRoster.Value && !(_joinTimer >= 0f)) { _leftSince.Remove(name); if (!_joinedSince.Contains(name)) { _joinedSince.Add(name); } _rosterTimer = Math.Max(0.5f, Settings.RosterDebounce.Value); } } private static void OnPlayerLeft(string name) { if (_inSession && Settings.ReportRoster.Value && !(_joinTimer >= 0f)) { _joinedSince.Remove(name); if (!_leftSince.Contains(name)) { _leftSince.Add(name); } _rosterTimer = Math.Max(0.5f, Settings.RosterDebounce.Value); } } private static void OnSceneInitialized() { if (_inSession && Settings.ReportLevel.Value && !(_joinTimer >= 0f)) { Dispatch(PresenceEvent.LevelChanged); } } private static void FlushRoster() { if (_joinedSince.Count != 0 || _leftSince.Count != 0) { List list = new List(2); if (_joinedSince.Count > 0) { list.Add("joined: " + string.Join(", ", _joinedSince)); } if (_leftSince.Count > 0) { list.Add("left: " + string.Join(", ", _leftSince)); } string summary = string.Join(" • ", list); _joinedSince.Clear(); _leftSince.Clear(); Dispatch(PresenceEvent.RosterChanged, summary); } } private static void Dispatch(PresenceEvent kind, string summary = "") { try { WebhookSender.Enqueue(PayloadBuilder.Build(FusionBridge.BuildSnapshot(kind, summary))); } catch (Exception value) { Log.Error($"Failed to build the {kind} payload: {value}"); } } public static void SendTest() { try { SessionSnapshot snap = ((!FusionBridge.IsFusionPresent() || !FusionBridge.HasServer()) ? new SessionSnapshot { Event = PresenceEvent.Test, LocalName = "Local player", LevelTitle = "Not in a Fusion session", PlayerCount = 0 } : FusionBridge.BuildSnapshot(PresenceEvent.Test)); WebhookSender.Enqueue(PayloadBuilder.Build(snap)); } catch (Exception value) { Log.Error($"Test post failed: {value}"); } } private static void Reset() { _inSession = false; _joinTimer = -1f; _rosterTimer = -1f; _joinedSince.Clear(); _leftSince.Clear(); } } internal enum PresenceEvent { Joined, Hosting, Left, RosterChanged, LevelChanged, Test } internal sealed class SessionSnapshot { public sealed class PlayerEntry { public string Username = ""; public string Nickname = ""; public string Permission = ""; public bool IsHost; public bool IsMe; } public PresenceEvent Event; public DateTime TimestampUtc = DateTime.UtcNow; public string LocalName = ""; public bool IsHost; public string LobbyName = ""; public string LobbyHostName = ""; public string LobbyCode = ""; public string Privacy = ""; public string LevelTitle = ""; public string LevelBarcode = ""; public int PlayerCount; public int MaxPlayers; public List Players = new List(); public string ChangeSummary = ""; } } namespace FusionPresence.Net { internal static class Json { public static string Quote(string value) { StringBuilder stringBuilder = new StringBuilder((value?.Length ?? 0) + 16); stringBuilder.Append('"'); Escape(stringBuilder, value); stringBuilder.Append('"'); return stringBuilder.ToString(); } public static void Escape(StringBuilder sb, string value) { if (string.IsNullOrEmpty(value)) { return; } foreach (char c in value) { switch (c) { case '"': sb.Append("\\\""); continue; case '\\': sb.Append("\\\\"); continue; case '\b': sb.Append("\\b"); continue; case '\f': sb.Append("\\f"); continue; case '\n': sb.Append("\\n"); continue; case '\r': sb.Append("\\r"); continue; case '\t': sb.Append("\\t"); continue; } if (c < ' ' || c == '\u007f') { StringBuilder stringBuilder = sb.Append("\\u"); int num = c; stringBuilder.Append(num.ToString("x4")); } else { sb.Append(c); } } } } internal static class PayloadBuilder { private const int FieldValueLimit = 1024; private const int TitleLimit = 256; private static readonly Regex RichText = new Regex("<[^>]*>", RegexOptions.Compiled); public static string Build(SessionSnapshot snap) { if (!Settings.DiscordEmbed.Value) { return BuildFlat(snap); } return BuildDiscord(snap); } private static string BuildDiscord(SessionSnapshot snap) { StringBuilder stringBuilder = new StringBuilder(768); stringBuilder.Append('{'); stringBuilder.Append("\"username\":").Append(Json.Quote(Trim(Settings.BotName.Value, 80))); stringBuilder.Append(",\"embeds\":[{"); stringBuilder.Append("\"title\":").Append(Json.Quote(Trim(Title(snap), 256))); stringBuilder.Append(",\"color\":").Append(Color(snap.Event)); stringBuilder.Append(",\"timestamp\":").Append(Json.Quote(snap.TimestampUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"))); List<(string, string, bool)> list = new List<(string, string, bool)>(); if (snap.Event != PresenceEvent.Left) { string item = (string.IsNullOrEmpty(snap.LevelTitle) ? "Unknown" : Clean(snap.LevelTitle)); list.Add(("Map", item, true)); list.Add(("Players", PlayerCountText(snap), true)); if (!string.IsNullOrEmpty(snap.Privacy)) { list.Add(("Privacy", Clean(snap.Privacy), true)); } if (!string.IsNullOrEmpty(snap.LobbyHostName)) { list.Add(("Host", Clean(snap.LobbyHostName), true)); } if (Settings.IncludeLevelBarcode.Value && !string.IsNullOrEmpty(snap.LevelBarcode)) { list.Add(("Barcode", "`" + Clean(snap.LevelBarcode) + "`", false)); } if (Settings.IncludeServerCode.Value && !string.IsNullOrEmpty(snap.LobbyCode)) { list.Add(("Join Code", "`" + Clean(snap.LobbyCode) + "`", true)); } if (!string.IsNullOrEmpty(snap.ChangeSummary)) { list.Add(("Change", Clean(snap.ChangeSummary), false)); } if (Settings.IncludePlayerList.Value && snap.Players.Count > 0) { list.Add(("In Lobby", PlayerListText(snap), false)); } } else if (!string.IsNullOrEmpty(snap.LevelTitle)) { list.Add(("Last Map", Clean(snap.LevelTitle), true)); } if (list.Count > 0) { stringBuilder.Append(",\"fields\":["); for (int i = 0; i < list.Count; i++) { if (i > 0) { stringBuilder.Append(','); } stringBuilder.Append('{'); stringBuilder.Append("\"name\":").Append(Json.Quote(list[i].Item1)); stringBuilder.Append(",\"value\":").Append(Json.Quote(Trim(list[i].Item2, 1024))); stringBuilder.Append(",\"inline\":").Append(list[i].Item3 ? "true" : "false"); stringBuilder.Append('}'); } stringBuilder.Append(']'); } string value = (string.IsNullOrEmpty(snap.LobbyName) ? "Fusion" : Clean(snap.LobbyName)); stringBuilder.Append(",\"footer\":{\"text\":").Append(Json.Quote(Trim(value, 2048))).Append('}'); stringBuilder.Append("}]}"); return stringBuilder.ToString(); } private static string Title(SessionSnapshot snap) { string text = (string.IsNullOrEmpty(snap.LocalName) ? "Someone" : Clean(snap.LocalName)); return snap.Event switch { PresenceEvent.Joined => text + " joined a Fusion lobby", PresenceEvent.Hosting => text + " is hosting a Fusion lobby", PresenceEvent.Left => text + " left the lobby", PresenceEvent.RosterChanged => "Lobby roster changed", PresenceEvent.LevelChanged => "Lobby moved to a new map", PresenceEvent.Test => "Fusion Presence test message", _ => "Fusion Presence", }; } private static int Color(PresenceEvent kind) { return kind switch { PresenceEvent.Joined => 3908957, PresenceEvent.Hosting => 5793266, PresenceEvent.Left => 10070709, PresenceEvent.RosterChanged => 16426522, PresenceEvent.LevelChanged => 45300, _ => 3092790, }; } private static string BuildFlat(SessionSnapshot snap) { StringBuilder stringBuilder = new StringBuilder(512); stringBuilder.Append('{'); stringBuilder.Append("\"event\":").Append(Json.Quote(snap.Event.ToString().ToLowerInvariant())); stringBuilder.Append(",\"timestamp\":").Append(Json.Quote(snap.TimestampUtc.ToString("o"))); stringBuilder.Append(",\"localPlayer\":").Append(Json.Quote(Clean(snap.LocalName))); stringBuilder.Append(",\"isHost\":").Append(snap.IsHost ? "true" : "false"); stringBuilder.Append(",\"lobbyName\":").Append(Json.Quote(Clean(snap.LobbyName))); stringBuilder.Append(",\"lobbyHost\":").Append(Json.Quote(Clean(snap.LobbyHostName))); stringBuilder.Append(",\"privacy\":").Append(Json.Quote(snap.Privacy)); stringBuilder.Append(",\"levelTitle\":").Append(Json.Quote(Clean(snap.LevelTitle))); stringBuilder.Append(",\"levelBarcode\":").Append(Json.Quote(snap.LevelBarcode)); stringBuilder.Append(",\"playerCount\":").Append(snap.PlayerCount); stringBuilder.Append(",\"maxPlayers\":").Append(snap.MaxPlayers); if (Settings.IncludeServerCode.Value) { stringBuilder.Append(",\"lobbyCode\":").Append(Json.Quote(snap.LobbyCode)); } if (!string.IsNullOrEmpty(snap.ChangeSummary)) { stringBuilder.Append(",\"change\":").Append(Json.Quote(Clean(snap.ChangeSummary))); } if (Settings.IncludePlayerList.Value) { stringBuilder.Append(",\"players\":["); for (int i = 0; i < snap.Players.Count; i++) { SessionSnapshot.PlayerEntry playerEntry = snap.Players[i]; if (i > 0) { stringBuilder.Append(','); } stringBuilder.Append('{'); stringBuilder.Append("\"username\":").Append(Json.Quote(Clean(playerEntry.Username))); if (Settings.IncludeNicknames.Value) { stringBuilder.Append(",\"nickname\":").Append(Json.Quote(Clean(playerEntry.Nickname))); } if (Settings.IncludePermissions.Value) { stringBuilder.Append(",\"permission\":").Append(Json.Quote(playerEntry.Permission)); } stringBuilder.Append(",\"isHost\":").Append(playerEntry.IsHost ? "true" : "false"); stringBuilder.Append(",\"isYou\":").Append(playerEntry.IsMe ? "true" : "false"); stringBuilder.Append('}'); } stringBuilder.Append(']'); } stringBuilder.Append('}'); return stringBuilder.ToString(); } private static string PlayerListText(SessionSnapshot snap) { StringBuilder stringBuilder = new StringBuilder(256); foreach (SessionSnapshot.PlayerEntry player in snap.Players) { string value = Clean(player.Username); if (string.IsNullOrEmpty(value)) { value = "(unnamed)"; } stringBuilder.Append("• ").Append(value); if (Settings.IncludeNicknames.Value) { string text = Clean(player.Nickname); if (!string.IsNullOrEmpty(text) && !text.Equals(value, StringComparison.OrdinalIgnoreCase)) { stringBuilder.Append(" *(").Append(text).Append(")*"); } } if (player.IsHost) { stringBuilder.Append(" — host"); } if (player.IsMe) { stringBuilder.Append(" — you"); } if (Settings.IncludePermissions.Value && !string.IsNullOrEmpty(player.Permission)) { stringBuilder.Append(" `").Append(player.Permission).Append('`'); } stringBuilder.Append('\n'); if (stringBuilder.Length > 960) { stringBuilder.Append("… list truncated"); break; } } return stringBuilder.ToString().TrimEnd('\n'); } private static string PlayerCountText(SessionSnapshot snap) { if (snap.MaxPlayers <= 0) { return snap.PlayerCount.ToString(); } return $"{snap.PlayerCount} / {snap.MaxPlayers}"; } private static string Clean(string value) { if (string.IsNullOrEmpty(value)) { return ""; } string text = RichText.Replace(value, ""); StringBuilder stringBuilder = new StringBuilder(text.Length + 8); string text2 = text; foreach (char c in text2) { if (c >= ' ') { bool flag; switch (c) { case '\u007f': break; case '#': case '*': case '>': case '@': case '\\': case '_': case '`': case '|': case '~': flag = true; goto IL_00c0; default: { flag = false; goto IL_00c0; } IL_00c0: if (flag) { stringBuilder.Append('\\'); } stringBuilder.Append(c); continue; } } if (stringBuilder.Length > 0 && stringBuilder[stringBuilder.Length - 1] != ' ') { stringBuilder.Append(' '); } } return stringBuilder.ToString().Trim(); } private static string Trim(string value, int max) { if (string.IsNullOrEmpty(value)) { return ""; } if (value.Length > max) { return value.Substring(0, max - 1) + "…"; } return value; } } internal static class WebhookSender { private const int QueueCapacity = 24; private static readonly HttpClient Client = new HttpClient { Timeout = TimeSpan.FromSeconds(10.0) }; private static BlockingCollection _queue; private static Thread _worker; private static volatile bool _running; public static string LastStatus { get; private set; } = "Nothing sent yet"; public static void Start() { if (!_running) { _queue = new BlockingCollection(24); _running = true; _worker = new Thread(WorkerLoop) { Name = "FusionPresence.Webhook", IsBackground = true }; _worker.Start(); } } public static void Enqueue(string json) { if (_running && _queue != null) { if (!Settings.Enabled.Value) { LastStatus = "Skipped — mod disabled"; } else if (!Settings.HasWebhook) { LastStatus = "Skipped — no webhook URL set"; Log.Warn("A presence event fired but no webhook URL is configured."); } else if (!_queue.TryAdd(json)) { LastStatus = "Dropped — send queue full"; Log.Warn("Webhook queue is full, dropping this event."); } } } public static void Stop(int flushMilliseconds = 2000) { if (_running) { _running = false; try { _queue?.CompleteAdding(); } catch { } try { _worker?.Join(flushMilliseconds); } catch { } _worker = null; _queue = null; } } private static void WorkerLoop() { string empty = string.Empty; try { foreach (string item in _queue.GetConsumingEnumerable()) { empty = Settings.WebhookUrl.Value; if (!string.IsNullOrWhiteSpace(empty)) { Send(empty, item); Thread.Sleep((int)(Math.Max(0.2f, Settings.MinSendSpacing.Value) * 1000f)); } } } catch (InvalidOperationException) { } catch (Exception ex2) { Log.Error($"Webhook worker died: {ex2}"); LastStatus = "Worker error — " + ex2.GetType().Name; } } private static void Send(string url, string payload, int attempt = 0) { try { using StringContent content = new StringContent(payload, Encoding.UTF8, "application/json"); using HttpResponseMessage httpResponseMessage = Client.PostAsync(url, content).GetAwaiter().GetResult(); int statusCode = (int)httpResponseMessage.StatusCode; if (statusCode == 429 && attempt < 3) { double num = 2.0; if (httpResponseMessage.Headers.TryGetValues("Retry-After", out IEnumerable values)) { foreach (string item in values) { if (double.TryParse(item, out var result)) { num = result; break; } } } LastStatus = $"Rate limited, retrying in {num:0.#}s"; Log.Warn(LastStatus); Thread.Sleep((int)(Math.Min(num, 30.0) * 1000.0)); Send(url, payload, attempt + 1); } else if (httpResponseMessage.IsSuccessStatusCode) { LastStatus = $"Sent OK ({statusCode}) at {DateTime.Now:HH:mm:ss}"; Log.Debug(LastStatus); } else { string value = SafeRead(httpResponseMessage); LastStatus = $"Failed — HTTP {statusCode}"; Log.Error($"Webhook rejected the post: HTTP {statusCode}. {value}"); } } catch (Exception ex) { LastStatus = "Failed — " + ex.GetType().Name; Log.Error("Webhook send failed: " + ex.Message); } } private static string SafeRead(HttpResponseMessage response) { try { string result = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); return (result.Length > 400) ? result.Substring(0, 400) : result; } catch { return "(no body)"; } } } } namespace FusionPresence.Menu { internal static class PresenceMenu { private static Page _root; private static FunctionElement _statusElement; private static FunctionElement _urlElement; private static StringElement _urlInput; public static void Create() { //IL_000a: Unknown result type (might be due to invalid IL or missing references) //IL_0025: Unknown result type (might be due to invalid IL or missing references) //IL_0072: Unknown result type (might be due to invalid IL or missing references) //IL_0084: Unknown result type (might be due to invalid IL or missing references) //IL_00b8: Unknown result type (might be due to invalid IL or missing references) //IL_0126: Unknown result type (might be due to invalid IL or missing references) //IL_0152: Unknown result type (might be due to invalid IL or missing references) //IL_0182: Unknown result type (might be due to invalid IL or missing references) //IL_01bc: Unknown result type (might be due to invalid IL or missing references) //IL_01eb: Unknown result type (might be due to invalid IL or missing references) //IL_022e: Unknown result type (might be due to invalid IL or missing references) //IL_0240: Unknown result type (might be due to invalid IL or missing references) //IL_027a: Unknown result type (might be due to invalid IL or missing references) //IL_02b4: Unknown result type (might be due to invalid IL or missing references) //IL_02ee: Unknown result type (might be due to invalid IL or missing references) //IL_0327: Unknown result type (might be due to invalid IL or missing references) //IL_0374: Unknown result type (might be due to invalid IL or missing references) //IL_0386: Unknown result type (might be due to invalid IL or missing references) //IL_03c0: Unknown result type (might be due to invalid IL or missing references) //IL_03fa: Unknown result type (might be due to invalid IL or missing references) //IL_0434: Unknown result type (might be due to invalid IL or missing references) //IL_046e: Unknown result type (might be due to invalid IL or missing references) //IL_04b6: Unknown result type (might be due to invalid IL or missing references) //IL_0503: Unknown result type (might be due to invalid IL or missing references) //IL_0515: Unknown result type (might be due to invalid IL or missing references) //IL_055e: Unknown result type (might be due to invalid IL or missing references) //IL_05a6: Unknown result type (might be due to invalid IL or missing references) //IL_0602: Unknown result type (might be due to invalid IL or missing references) //IL_0614: Unknown result type (might be due to invalid IL or missing references) //IL_0649: Unknown result type (might be due to invalid IL or missing references) //IL_0679: Unknown result type (might be due to invalid IL or missing references) //IL_06a8: Unknown result type (might be due to invalid IL or missing references) _root = Page.Root.CreatePage("Fusion Presence", Color.cyan, 0, true); _root.CreateBool("Enabled", Color.white, Settings.Enabled.Value, (Action)delegate(bool v) { Set(Settings.Enabled, v); }); Page obj = _root.CreatePage("Webhook", new Color(0.4f, 0.8f, 1f), 0, true); _urlElement = obj.CreateFunction(UrlLabel(), Color.grey, (Action)delegate { }); _urlInput = obj.CreateString("Set URL", Color.white, string.Empty, (Action)delegate { CommitUrl(); }); StringElement urlInput = _urlInput; ((Element)urlInput).OnElementChanged = (Action)Delegate.Combine(((Element)urlInput).OnElementChanged, new Action(CommitUrl)); obj.CreateFunction("Save URL", Color.green, (Action)CommitUrl); obj.CreateFunction("Clear URL", Color.red, (Action)delegate { Settings.WebhookUrl.Value = string.Empty; Settings.Cat.SaveToFile(false); if (_urlInput != null) { _urlInput.Value = string.Empty; } if (_urlElement != null) { ((Element)_urlElement).ElementName = UrlLabel(); } RefreshStatus(); }); obj.CreateBool("Discord embed format", Color.white, Settings.DiscordEmbed.Value, (Action)delegate(bool v) { Set(Settings.DiscordEmbed, v); }); obj.CreateFunction("Send test message", Color.green, (Action)delegate { PresenceTracker.SendTest(); RefreshStatus(); }); _statusElement = obj.CreateFunction(StatusLabel(), Color.grey, (Action)RefreshStatus); Page obj2 = _root.CreatePage("Events", new Color(0.6f, 1f, 0.6f), 0, true); obj2.CreateBool("On joining a lobby", Color.white, Settings.ReportJoin.Value, (Action)delegate(bool v) { Set(Settings.ReportJoin, v); }); obj2.CreateBool("On hosting", Color.white, Settings.ReportHost.Value, (Action)delegate(bool v) { Set(Settings.ReportHost, v); }); obj2.CreateBool("On leaving", Color.white, Settings.ReportLeave.Value, (Action)delegate(bool v) { Set(Settings.ReportLeave, v); }); obj2.CreateBool("On player join / leave", Color.white, Settings.ReportRoster.Value, (Action)delegate(bool v) { Set(Settings.ReportRoster, v); }); obj2.CreateBool("On map change", Color.white, Settings.ReportLevel.Value, (Action)delegate(bool v) { Set(Settings.ReportLevel, v); }); Page obj3 = _root.CreatePage("Message Contents", new Color(1f, 0.85f, 0.4f), 0, true); obj3.CreateBool("Include your own name", Color.white, Settings.IncludeOwnName.Value, (Action)delegate(bool v) { Set(Settings.IncludeOwnName, v); }); obj3.CreateBool("Player list", Color.white, Settings.IncludePlayerList.Value, (Action)delegate(bool v) { Set(Settings.IncludePlayerList, v); }); obj3.CreateBool("Nicknames", Color.white, Settings.IncludeNicknames.Value, (Action)delegate(bool v) { Set(Settings.IncludeNicknames, v); }); obj3.CreateBool("Permission levels", Color.white, Settings.IncludePermissions.Value, (Action)delegate(bool v) { Set(Settings.IncludePermissions, v); }); obj3.CreateBool("Level barcode", Color.white, Settings.IncludeLevelBarcode.Value, (Action)delegate(bool v) { Set(Settings.IncludeLevelBarcode, v); }); obj3.CreateBool("[!] Lobby join code", new Color(1f, 0.5f, 0.5f), Settings.IncludeServerCode.Value, (Action)delegate(bool v) { Set(Settings.IncludeServerCode, v); if (v) { Log.Warn("Join code will now be posted. Anyone who can read that webhook target can join your lobby."); } }); Page obj4 = _root.CreatePage("Timing", new Color(0.8f, 0.8f, 0.8f), 0, true); obj4.CreateFloat("Join delay (s)", Color.white, Settings.JoinDelay.Value, 0.5f, 0f, 15f, (Action)delegate(float v) { Set(Settings.JoinDelay, v); }); obj4.CreateFloat("Roster debounce (s)", Color.white, Settings.RosterDebounce.Value, 0.5f, 0.5f, 30f, (Action)delegate(float v) { Set(Settings.RosterDebounce, v); }); obj4.CreateFloat("Min send gap (s)", Color.white, Settings.MinSendSpacing.Value, 0.1f, 0.2f, 10f, (Action)delegate(float v) { Set(Settings.MinSendSpacing, v); }); Page obj5 = _root.CreatePage("Diagnostics", new Color(1f, 0.6f, 0.2f), 0, true); obj5.CreateBool("Verbose logging", Color.white, Log.Verbose, (Action)delegate(bool v) { Log.Verbose = v; }); obj5.CreateFunction("Check Fusion link", Color.white, (Action)delegate { bool flag = FusionBridge.IsFusionPresent(); bool subscribed = FusionBridge.Subscribed; bool value = flag && FusionBridge.HasServer(); bool value2 = flag && FusionBridge.LobbyInfoReady(); Log.Msg($"Fusion loaded: {flag} | hooks attached: {subscribed} | in a session: {value} | lobby info received: {value2}"); }); obj5.CreateFunction("Save settings now", Color.white, (Action)delegate { Settings.Cat.SaveToFile(false); }); obj5.CreateFunction("Dump settings to log", Color.white, (Action)delegate { Log.Msg("--- FusionPresence live settings ---"); foreach (MelonPreferences_Entry entry in Settings.Cat.Entries) { if (!(entry.Identifier == ((MelonPreferences_Entry)Settings.WebhookUrl).Identifier)) { Log.Msg(" " + entry.Identifier + " = " + entry.GetValueAsString()); } } Log.Msg($" (webhook set: {Settings.HasWebhook})"); Log.Msg("------------------------------------"); }); } public static void Destroy() { if (_root != null) { try { Page.Root.RemovePage(_root); } catch { } _root = null; _statusElement = null; _urlElement = null; _urlInput = null; } } private static void CommitUrl() { if (_urlInput == null) { return; } string text = (_urlInput.Value ?? string.Empty).Trim(); if (!(text == Settings.WebhookUrl.Value)) { Settings.WebhookUrl.Value = text; Settings.Cat.SaveToFile(false); if (_urlElement != null) { ((Element)_urlElement).ElementName = UrlLabel(); } RefreshStatus(); Log.Msg(string.IsNullOrEmpty(text) ? "Webhook URL cleared." : "Webhook URL saved."); } } private static void Set(MelonPreferences_Entry entry, T value) { entry.Value = value; Settings.Cat.SaveToFile(false); Log.Debug($"{((MelonPreferences_Entry)entry).Identifier} = {value}"); } private static void RefreshStatus() { if (_statusElement != null) { ((Element)_statusElement).ElementName = StatusLabel(); } } private static string StatusLabel() { return "Status: " + WebhookSender.LastStatus; } private static string UrlLabel() { string value = Settings.WebhookUrl.Value; if (string.IsNullOrWhiteSpace(value)) { return "URL: (not set)"; } string text = ((value.Length > 6) ? value.Substring(value.Length - 6) : value); return "URL: set (…" + text + ")"; } } } namespace FusionPresence.Config { internal static class Settings { private const string Category = "FusionPresence"; public static MelonPreferences_Category Cat { get; private set; } public static MelonPreferences_Entry Enabled { get; private set; } public static MelonPreferences_Entry WebhookUrl { get; private set; } public static MelonPreferences_Entry DiscordEmbed { get; private set; } public static MelonPreferences_Entry BotName { get; private set; } public static MelonPreferences_Entry ReportJoin { get; private set; } public static MelonPreferences_Entry ReportHost { get; private set; } public static MelonPreferences_Entry ReportLeave { get; private set; } public static MelonPreferences_Entry ReportRoster { get; private set; } public static MelonPreferences_Entry ReportLevel { get; private set; } public static MelonPreferences_Entry IncludeOwnName { get; private set; } public static MelonPreferences_Entry IncludePlayerList { get; private set; } public static MelonPreferences_Entry IncludeNicknames { get; private set; } public static MelonPreferences_Entry IncludePermissions { get; private set; } public static MelonPreferences_Entry IncludeLevelBarcode { get; private set; } public static MelonPreferences_Entry IncludeServerCode { get; private set; } public static MelonPreferences_Entry JoinDelay { get; private set; } public static MelonPreferences_Entry RosterDebounce { get; private set; } public static MelonPreferences_Entry MinSendSpacing { get; private set; } public static bool HasWebhook { get { if (!string.IsNullOrWhiteSpace(WebhookUrl.Value)) { return WebhookUrl.Value.StartsWith("http", StringComparison.OrdinalIgnoreCase); } return false; } } public static void Register() { Cat = MelonPreferences.CreateCategory("FusionPresence", "Fusion Presence"); Enabled = Cat.CreateEntry("Enabled", true, (string)null, "Master switch. When false nothing is ever sent.", false, false, (ValueValidator)null, (string)null); WebhookUrl = Cat.CreateEntry("WebhookUrl", string.Empty, (string)null, "Full webhook URL. Discord webhooks look like https://discord.com/api/webhooks//", false, false, (ValueValidator)null, (string)null); DiscordEmbed = Cat.CreateEntry("DiscordEmbed", true, (string)null, "true = Discord embed payload. false = flat JSON, for your own endpoint.", false, false, (ValueValidator)null, (string)null); BotName = Cat.CreateEntry("BotName", "Fusion Presence", (string)null, "Display name on the Discord post. Ignored in flat JSON mode.", false, false, (ValueValidator)null, (string)null); ReportJoin = Cat.CreateEntry("ReportJoin", true, (string)null, "Post when you join someone else's lobby.", false, false, (ValueValidator)null, (string)null); ReportHost = Cat.CreateEntry("ReportHost", true, (string)null, "Post when you start hosting.", false, false, (ValueValidator)null, (string)null); ReportLeave = Cat.CreateEntry("ReportLeave", true, (string)null, "Post when you disconnect.", false, false, (ValueValidator)null, (string)null); ReportRoster = Cat.CreateEntry("ReportRoster", false, (string)null, "Post when players join or leave while you're in a lobby.", false, false, (ValueValidator)null, (string)null); ReportLevel = Cat.CreateEntry("ReportLevel", true, (string)null, "Post when the lobby changes level.", false, false, (ValueValidator)null, (string)null); IncludeOwnName = Cat.CreateEntry("IncludeOwnName", true, (string)null, "Include your own username. Turn off to report anonymously.", false, false, (ValueValidator)null, (string)null); IncludePlayerList = Cat.CreateEntry("IncludePlayerList", true, (string)null, "List everyone in the lobby by name.", false, false, (ValueValidator)null, (string)null); IncludeNicknames = Cat.CreateEntry("IncludeNicknames", true, (string)null, "Show nicknames alongside usernames when they differ.", false, false, (ValueValidator)null, (string)null); IncludePermissions = Cat.CreateEntry("IncludePermissions", false, (string)null, "Tag each player with their permission level.", false, false, (ValueValidator)null, (string)null); IncludeLevelBarcode = Cat.CreateEntry("IncludeLevelBarcode", false, (string)null, "Include the raw level barcode, not just the title.", false, false, (ValueValidator)null, (string)null); IncludeServerCode = Cat.CreateEntry("IncludeServerCode", false, (string)null, "Include the lobby join code. OFF by default - anyone who can read the webhook target could join your lobby.", false, false, (ValueValidator)null, (string)null); JoinDelay = Cat.CreateEntry("JoinDelaySeconds", 4f, (string)null, "Wait this long after joining before posting, so the host's lobby info has arrived.", false, false, (ValueValidator)null, (string)null); RosterDebounce = Cat.CreateEntry("RosterDebounceSeconds", 6f, (string)null, "Coalesce a burst of joins/leaves into one post.", false, false, (ValueValidator)null, (string)null); MinSendSpacing = Cat.CreateEntry("MinSendSpacingSeconds", 1.5f, (string)null, "Minimum gap between outgoing requests. Keeps you under Discord's webhook rate limit.", false, false, (ValueValidator)null, (string)null); } } }