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 StreamChats v1.0.0
StreamChats.dll
Decompiled 5 hours agousing System; using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Net; using System.Net.Sockets; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Security; using System.Security.Authentication; using System.Security.Permissions; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using BepInEx; using BepInEx.Configuration; using BepInEx.Logging; using ChzzkChat.Configuration; using ChzzkChat.Utils; using ChzzkChat.chzzk; using HarmonyLib; using Microsoft.CodeAnalysis; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using TMPro; using UnityEngine; using WebSocketSharp; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.Default | DebuggableAttribute.DebuggingModes.DisableOptimizations | DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints | DebuggableAttribute.DebuggingModes.EnableEditAndContinue)] [assembly: IgnoresAccessChecksTo("0Harmony")] [assembly: IgnoresAccessChecksTo("Assembly-CSharp")] [assembly: IgnoresAccessChecksTo("BepInEx")] [assembly: IgnoresAccessChecksTo("Unity.Netcode.Runtime")] [assembly: IgnoresAccessChecksTo("Unity.TextMeshPro")] [assembly: IgnoresAccessChecksTo("UnityEngine.CoreModule")] [assembly: IgnoresAccessChecksTo("UnityEngine")] [assembly: IgnoresAccessChecksTo("UnityEngine.IMGUIModule")] [assembly: IgnoresAccessChecksTo("UnityEngine.InputLegacyModule")] [assembly: IgnoresAccessChecksTo("UnityEngine.TextRenderingModule")] [assembly: IgnoresAccessChecksTo("UnityEngine.UI")] [assembly: IgnoresAccessChecksTo("UnityEngine.UIModule")] [assembly: TargetFramework(".NETStandard,Version=v2.1", FrameworkDisplayName = ".NET Standard 2.1")] [assembly: AssemblyCompany("StreamChats")] [assembly: AssemblyConfiguration("Debug")] [assembly: AssemblyDescription("Bring CHZZK, Twitch, and YouTube chat into Lethal Company chat.")] [assembly: AssemblyFileVersion("1.1.5.0")] [assembly: AssemblyInformationalVersion("1.1.5+8edf1725d589ddff9379892fe8e0cbef462e98a8")] [assembly: AssemblyProduct("StreamChats")] [assembly: AssemblyTitle("StreamChats")] [assembly: SecurityPermission(SecurityAction.RequestMinimum, SkipVerification = true)] [assembly: AssemblyVersion("1.1.5.0")] [module: UnverifiableCode] [module: RefSafetyRules(11)] 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; } } [CompilerGenerated] [Microsoft.CodeAnalysis.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 StreamChats { public static class MyPluginInfo { public const string PLUGIN_GUID = "StreamChats"; public const string PLUGIN_NAME = "StreamChats"; public const string PLUGIN_VERSION = "1.1.5"; } } namespace ChzzkChat { [BepInPlugin("asta.lethalcompany.streamchats", "StreamChats", "1.1.5")] public class Plugin : BaseUnityPlugin { private readonly struct PendingChatMessage { public string Name { get; } public string Message { get; } public int Amount { get; } public bool IsDonation { get; } private PendingChatMessage(string name, string message, int amount, bool isDonation) { Name = name; Message = message; Amount = amount; IsDonation = isDonation; } public static PendingChatMessage Chat(string name, string message) { return new PendingChatMessage(name, message, 0, isDonation: false); } public static PendingChatMessage Donation(string name, string message, int amount) { return new PendingChatMessage(name, message, amount, isDonation: true); } } private const string PluginGuid = "asta.lethalcompany.streamchats"; private const int MaxMainThreadActionsPerFrame = 100; private const int MaxPendingMessages = 30; private const int HudWaitTimeoutSeconds = 120; private static readonly string[] Colors = new string[14] { "#f4dbd6", "#f0c6c6", "#f5bde6", "#c6a0f6", "#ed8796", "#ee99a0", "#f5a97f", "#eed49f", "#a6da95", "#8bd5ca", "#91d7e3", "#7dc4e4", "#8aadf4", "#b7bdf8" }; private readonly Harmony harmony = new Harmony("asta.lethalcompany.streamchats"); private static readonly ConcurrentQueue<PendingChatMessage> PendingMessages = new ConcurrentQueue<PendingChatMessage>(); private static int colorIndex; private static int displayedMessages; private static bool loggedHudWait; private static DateTime? firstQueueTime; private static Plugin? instance; private static bool gameLoopPumpLogged; private static DateTime nextGameLoopPumpStatusLog; private static int gameLoopPumpCalls; private static bool applicationQuitting; private static bool quitHookRegistered; private static ChzzkUnity? chzzkUnity; private static TwitchChatClient? twitchClient; private static YoutubeLiveChatClient? youtubeClient; internal static ManualLogSource? logger; private bool updatePumpLogged; private bool coroutinePumpLogged; private void Awake() { if ((Object)(object)instance != (Object)null && (Object)(object)instance != (Object)(object)this) { ((BaseUnityPlugin)this).Logger.LogWarning((object)"Duplicate StreamChats plugin instance detected. Disabling this instance."); ((Behaviour)this).enabled = false; return; } instance = this; harmony.PatchAll(); logger = ((BaseUnityPlugin)this).Logger; RegisterQuitHook(); Config.Load(); MainThreadDispatcher.Initialize(); PatchGameLoopPump(); ((BaseUnityPlugin)this).Logger.LogInfo((object)"StreamChats loaded."); StartChatClients(); ((MonoBehaviour)this).StartCoroutine(MainThreadPump()); } private void OnDestroy() { ((BaseUnityPlugin)this).Logger.LogInfo((object)$"StreamChats plugin OnDestroy. applicationQuitting={applicationQuitting}"); if (!applicationQuitting) { ((BaseUnityPlugin)this).Logger.LogInfo((object)"Ignoring non-quit OnDestroy so the CHZZK websocket can keep running."); return; } StopChatClients("plugin destroyed during application quit"); harmony.UnpatchSelf(); if ((Object)(object)instance == (Object)(object)this) { instance = null; } } private void OnApplicationQuit() { applicationQuitting = true; StopChatClients("application quit"); } private void Update() { if (!updatePumpLogged) { updatePumpLogged = true; ((BaseUnityPlugin)this).Logger.LogInfo((object)"Plugin.Update main thread pump is running."); } ProcessMainThreadWork(); } private static void RegisterQuitHook() { if (!quitHookRegistered) { quitHookRegistered = true; Application.quitting += delegate { applicationQuitting = true; StopChatClients("Application.quitting"); }; } } private void PatchGameLoopPump() { //IL_003d: Unknown result type (might be due to invalid IL or missing references) //IL_0043: Expected O, but got Unknown MethodInfo method = typeof(Plugin).GetMethod("GameLoopPumpPostfix", BindingFlags.Static | BindingFlags.NonPublic); if (method == null) { ((BaseUnityPlugin)this).Logger.LogWarning((object)"Could not find StreamChats main thread pump postfix."); return; } HarmonyMethod val = new HarmonyMethod(method); int num = 0; string[] array = new string[7] { "HUDManager.Update", "HUDManager.LateUpdate", "MenuManager.Update", "QuickMenuManager.Update", "PlayerControllerB.Update", "StartOfRound.Update", "GameNetworkManager.Update" }; string[] array2 = array; foreach (string text in array2) { int num2 = text.LastIndexOf('.'); string text2 = text.Substring(0, num2); string text3 = text.Substring(num2 + 1); Type type = AccessTools.TypeByName(text2); MethodInfo methodInfo = ((type == null) ? null : AccessTools.Method(type, text3, (Type[])null, (Type[])null)); if (!(methodInfo == null)) { harmony.Patch((MethodBase)methodInfo, (HarmonyMethod)null, val, (HarmonyMethod)null, (HarmonyMethod)null, (HarmonyMethod)null); num++; ((BaseUnityPlugin)this).Logger.LogInfo((object)("Patched StreamChats main thread pump into " + text2 + "." + text3 + ".")); } } if (num == 0) { ((BaseUnityPlugin)this).Logger.LogWarning((object)"Could not find any game loop method for StreamChats main thread pump."); } } private static void GameLoopPumpPostfix() { gameLoopPumpCalls++; if (!gameLoopPumpLogged) { gameLoopPumpLogged = true; nextGameLoopPumpStatusLog = DateTime.UtcNow.AddSeconds(5.0); ManualLogSource? obj = logger; if (obj != null) { obj.LogInfo((object)"Game loop main thread pump is running."); } } ProcessMainThreadWork(); if (DateTime.UtcNow >= nextGameLoopPumpStatusLog) { ManualLogSource? obj2 = logger; if (obj2 != null) { obj2.LogInfo((object)($"Game loop pump alive. queued callbacks: {MainThreadDispatcher.Count}, " + $"pending HUD messages: {PendingMessages.Count}, hudReady: {IsHudReady()}, " + $"calls: {gameLoopPumpCalls}")); } nextGameLoopPumpStatusLog = DateTime.UtcNow.AddSeconds(5.0); } } private IEnumerator MainThreadPump() { DateTime nextStatusLog = DateTime.UtcNow.AddSeconds(5.0); while (((Behaviour)this).enabled) { if (!coroutinePumpLogged) { coroutinePumpLogged = true; ((BaseUnityPlugin)this).Logger.LogInfo((object)"Coroutine main thread pump is running."); } ProcessMainThreadWork(); if (DateTime.UtcNow >= nextStatusLog) { ((BaseUnityPlugin)this).Logger.LogInfo((object)($"Main thread pump alive. queued callbacks: {MainThreadDispatcher.Count}, " + $"pending HUD messages: {PendingMessages.Count}, hudReady: {IsHudReady()}")); nextStatusLog = DateTime.UtcNow.AddSeconds(5.0); } yield return null; } } private static void ProcessMainThreadWork() { for (int i = 0; i < 100; i++) { if (!MainThreadDispatcher.TryDequeue(out Action action)) { break; } if (action == null) { break; } try { action(); } catch (Exception arg) { ManualLogSource? obj = logger; if (obj != null) { obj.LogError((object)$"Main thread callback failed: {arg}"); } } } FlushPendingMessages(); } private void StartChatClients() { StopChatClients("restarting chat clients"); bool flag = !string.IsNullOrWhiteSpace(Config.ConfigChannelId); bool flag2 = !string.IsNullOrWhiteSpace(Config.TwitchHandle); bool flag3 = !string.IsNullOrWhiteSpace(Config.YoutubeHandle); ((BaseUnityPlugin)this).Logger.LogInfo((object)$"Configured stream chats: CHZZK={flag}, Twitch={flag2}, YouTube={flag3}"); if (flag) { StartChzzkClient(); } if (flag2) { StartTwitchClient(); } if (flag3) { StartYoutubeClient(); } if (!flag && !flag2 && !flag3) { ((BaseUnityPlugin)this).Logger.LogWarning((object)"No stream chat target configured. Fill ChannelId, TwitchHandle, or YoutubeHandle in StreamChats.cfg."); } } private void StartChzzkClient() { if (string.IsNullOrWhiteSpace(Config.ConfigChannelId)) { ((BaseUnityPlugin)this).Logger.LogWarning((object)"ChannelId is empty. Set it in BepInEx/config/StreamChats.cfg."); return; } if (chzzkUnity != null) { chzzkUnity.StopListening("restarting CHZZK client"); chzzkUnity = null; } chzzkUnity = new ChzzkUnity(); ((BaseUnityPlugin)this).Logger.LogInfo((object)"Starting CHZZK chat client."); chzzkUnity.OnMessage += delegate(ChzzkUnity.Profile profile, string msg) { ShowPlatformMessage("CHZZK", profile.nickname, msg); }; chzzkUnity.OnDonation += delegate(ChzzkUnity.Profile profile, string msg, ChzzkUnity.DonationExtras donation) { ShowDonation(profile.nickname, donation.payAmount, msg); }; chzzkUnity.OnSubscription += delegate(ChzzkUnity.Profile profile, ChzzkUnity.SubscriptionExtras subscription) { ShowPlatformMessage("CHZZK", "CHZZK", $"{profile.nickname}님이 {subscription.month}개월 구독했어요!"); }; chzzkUnity.OnClose += delegate { ((BaseUnityPlugin)this).Logger.LogWarning((object)"Disconnected from CHZZK chat. Reconnecting soon."); }; chzzkUnity.Connect(Config.ConfigChannelId); } private void StartTwitchClient() { if (string.IsNullOrWhiteSpace(Config.TwitchHandle)) { ((BaseUnityPlugin)this).Logger.LogWarning((object)"TwitchHandle is empty. Set it in BepInEx/config/StreamChats.cfg."); return; } twitchClient = new TwitchChatClient(); ((BaseUnityPlugin)this).Logger.LogInfo((object)"Starting Twitch chat client."); twitchClient.OnMessage += delegate(string name, string msg) { ShowPlatformMessage("Twitch", name, msg); }; twitchClient.Connect(Config.TwitchHandle); } private void StartYoutubeClient() { if (string.IsNullOrWhiteSpace(Config.YoutubeHandle)) { ((BaseUnityPlugin)this).Logger.LogWarning((object)"YoutubeHandle is empty. Set it in BepInEx/config/StreamChats.cfg."); return; } youtubeClient = new YoutubeLiveChatClient(); ((BaseUnityPlugin)this).Logger.LogInfo((object)"Starting YouTube chat client."); youtubeClient.OnMessage += delegate(string name, string msg) { ShowPlatformMessage("YouTube", name, msg); }; youtubeClient.Connect(Config.YoutubeHandle); } private static void StopChatClients(string reason) { if (chzzkUnity != null) { chzzkUnity.StopListening(reason); chzzkUnity = null; } if (twitchClient != null) { twitchClient.StopListening(reason); twitchClient = null; } if (youtubeClient != null) { youtubeClient.StopListening(reason); youtubeClient = null; } } public static void ShowMessage(string name, string msg) { try { if (!IsHudReady()) { QueuePendingMessage(PendingChatMessage.Chat(name, msg)); } else { ShowMessageNow(name, msg); } } catch (Exception ex) { ManualLogSource? obj = logger; if (obj != null) { obj.LogError((object)("Error showing message: " + ex.Message)); } } } public static void ShowPlatformMessage(string platform, string name, string msg) { string name2 = ((!Config.ShowPlatformPrefix || string.IsNullOrWhiteSpace(platform)) ? name : ("[" + platform + "] " + name)); ShowMessage(name2, msg); } public static void ShowDonation(string name, int amount, string msg) { try { if (!IsHudReady()) { QueuePendingMessage(PendingChatMessage.Donation(name, msg, amount)); } else { ShowDonationNow(name, amount, msg); } } catch (Exception ex) { ManualLogSource? obj = logger; if (obj != null) { obj.LogError((object)("Error showing donation: " + ex.Message)); } } } internal static void FlushPendingMessages() { if (!IsHudReady()) { return; } int num = 0; PendingChatMessage result; while (PendingMessages.TryDequeue(out result)) { try { if (result.IsDonation) { ShowDonationNow(result.Name, result.Amount, result.Message); } else { ShowMessageNow(result.Name, result.Message); } num++; } catch (Exception ex) { ManualLogSource? obj = logger; if (obj != null) { obj.LogError((object)("Error showing queued CHZZK message: " + ex.Message)); } break; } } if (num > 0) { loggedHudWait = false; firstQueueTime = null; ManualLogSource? obj2 = logger; if (obj2 != null) { obj2.LogInfo((object)$"Displayed {num} queued CHZZK chat message(s)."); } } } private static void ShowMessageNow(string name, string msg) { string text = (string.IsNullOrWhiteSpace(name) ? "CHZZK" : name); string text2 = NextColor(); string item = "<color=" + text2 + ">" + EscapeRichText(text) + "</color>: <color=#FFFFFF>" + EscapeRichText(msg) + "</color>"; HUDManager.Instance.ChatMessageHistory.Add(item); if (HUDManager.Instance.ChatMessageHistory.Count > 30) { HUDManager.Instance.ChatMessageHistory.RemoveAt(0); } ((TMP_Text)HUDManager.Instance.chatText).text = "\n" + string.Join("\n", HUDManager.Instance.ChatMessageHistory); HUDManager.Instance.PingHUDElement(HUDManager.Instance.Chat, 4f, 1f, 0.2f); LogDisplayedMessage(text); } private static void ShowDonationNow(string name, int amount, string msg) { string arg = EscapeRichText(string.IsNullOrWhiteSpace(name) ? "익명" : name); HUDManager.Instance.DisplayTip($"{arg}님이 {amount:N0}원 후원하셨어요!", EscapeRichText(msg) ?? "", false, false, "LC_Tip1"); HUDManager.Instance.PingHUDElement(HUDManager.Instance.Chat, 2f, 1f, 0.2f); } private static bool IsHudReady() { return (Object)(object)HUDManager.Instance != (Object)null && (Object)(object)HUDManager.Instance.chatText != (Object)null && HUDManager.Instance.Chat != null && HUDManager.Instance.ChatMessageHistory != null; } private static string NextColor() { string result = Colors[colorIndex++]; if (colorIndex >= Colors.Length) { colorIndex = 0; } return result; } private static void LogDisplayedMessage(string name) { displayedMessages++; if (displayedMessages <= 10 || displayedMessages % 25 == 0) { ManualLogSource? obj = logger; if (obj != null) { obj.LogInfo((object)$"Displayed CHZZK chat #{displayedMessages} in HUD: {name}"); } } } private static void QueuePendingMessage(PendingChatMessage message) { firstQueueTime = firstQueueTime ?? DateTime.UtcNow; PendingMessages.Enqueue(message); PendingChatMessage result; while (PendingMessages.Count > 30) { PendingMessages.TryDequeue(out result); } if (firstQueueTime.HasValue && (DateTime.UtcNow - firstQueueTime.Value).TotalSeconds > 120.0) { while (PendingMessages.TryDequeue(out result)) { } firstQueueTime = null; loggedHudWait = false; ManualLogSource? obj = logger; if (obj != null) { obj.LogWarning((object)"HUD did not become ready in time. Dropped queued CHZZK messages."); } } else if (!loggedHudWait) { loggedHudWait = true; ManualLogSource? obj2 = logger; if (obj2 != null) { obj2.LogInfo((object)"HUD is not ready yet; queueing CHZZK chat until it can be displayed."); } } } private static string EscapeRichText(string value) { if (string.IsNullOrEmpty(value)) { return string.Empty; } return value.Replace("&", "&").Replace("<", "<").Replace(">", ">"); } } } namespace ChzzkChat.Utils { internal static class MainThreadDispatcher { private static readonly ConcurrentQueue<Action> Actions = new ConcurrentQueue<Action>(); internal static int Count => Actions.Count; internal static void Initialize() { } internal static void Enqueue(Action action) { Actions.Enqueue(action); } internal static bool TryDequeue(out Action? action) { return Actions.TryDequeue(out action); } } } namespace ChzzkChat.chzzk { public sealed class ChzzkUnity { private enum SslProtocolsHack { Tls = 192, Tls11 = 768, Tls12 = 3072 } [Serializable] public class LiveDetail { [Serializable] public class Content { public string liveTitle = string.Empty; public string status = string.Empty; public string chatChannelId = string.Empty; public bool chatActive; } public int code; public string message = string.Empty; public Content? content; } [Serializable] public class LiveStatus { [Serializable] public class Content { public string liveTitle = string.Empty; public string status = string.Empty; public int concurrentUserCount; public int accumulateCount; public bool paidPromotion; public bool adult; public string chatChannelId = string.Empty; public string categoryType = string.Empty; public string liveCategory = string.Empty; public string liveCategoryValue = string.Empty; public string livePollingStatusJson = string.Empty; public string faultStatus = string.Empty; public string userAdultStatus = string.Empty; public bool chatActive; public string chatAvailableGroup = string.Empty; public string chatAvailableCondition = string.Empty; public int minFollowerMinute; } public int code; public string message = string.Empty; public Content? content; } [Serializable] public class AccessTokenResult { [Serializable] public class Content { public string accessToken = string.Empty; public bool realNameAuth; public string extraToken = string.Empty; } public int code; public string message = string.Empty; public Content? content; } [Serializable] public class Profile { [Serializable] public class StreamingProperty { } [Serializable] public class ActivityBadge { public int badgeNo; public string badgeId = string.Empty; public string imageUrl = string.Empty; public bool activated; } public string userIdHash = string.Empty; public string nickname = string.Empty; public string profileImageUrl = string.Empty; public string userRoleCode = string.Empty; public string badge = string.Empty; public string title = string.Empty; public bool verifiedMark; public List<ActivityBadge> activityBadges = new List<ActivityBadge>(); public StreamingProperty streamingProperty = new StreamingProperty(); public static Profile Unknown() { return FromNickname("알 수 없음"); } public static Profile FromNickname(string value) { return new Profile { nickname = (string.IsNullOrWhiteSpace(value) ? "알 수 없음" : value) }; } } [Serializable] public class SubscriptionExtras { public int month; public string tierName = string.Empty; public string nickname = string.Empty; public int tierNo; } [Serializable] public class DonationExtras { [Serializable] public class WeeklyRank { public string userIdHash = string.Empty; public string nickName = string.Empty; public bool verifiedMark; public int donationAmount; public int ranking; } public bool isAnonymous; public string payType = string.Empty; public int payAmount; public string streamingChannelId = string.Empty; public string nickname = string.Empty; public string osType = string.Empty; public string donationType = string.Empty; public List<WeeklyRank> weeklyRankList = new List<WeeklyRank>(); public WeeklyRank? donationUserWeeklyRank; } [Serializable] public class ChannelInfo { [Serializable] public class Content { public string channelId = string.Empty; public string channelName = string.Empty; public string channelImageUrl = string.Empty; public bool verifiedMark; public string channelType = string.Empty; public string channelDescription = string.Empty; public int followerCount; public bool openLive; } public int code; public string message = string.Empty; public Content? content; } private const string ChzzkApiBaseUrl = "https://api.chzzk.naver.com"; private const string GameApiBaseUrl = "https://comm-api.game.naver.com/nng_main"; private const string ClientPing = "{\"ver\":\"2\",\"cmd\":0}"; private const string ServerPingPong = "{\"ver\":\"2\",\"cmd\":10000}"; private const int InitialHeartbeatDelayMilliseconds = 2000; private const int HeartbeatIntervalMilliseconds = 20000; private const int HttpRequestTimeoutMilliseconds = 15000; private const int ReconnectDelayMilliseconds = 5000; private const int SocketInactivityTimeoutMilliseconds = 30000; private const int WatchdogIntervalMilliseconds = 3000; private static int nextInstanceId; private readonly ConcurrentQueue<string> sendQueue = new ConcurrentQueue<string>(); private readonly int instanceId = Interlocked.Increment(ref nextInstanceId); private CancellationTokenSource? cancellation; private Thread? worker; private WebSocket? socket; private string channel = string.Empty; private int receivedChatMessages; private int heartbeatResponses; private long lastFrameTicks; private bool openEventRaised; public int connected; public event Action<Profile, string>? OnMessage; public event Action<Profile, string, DonationExtras>? OnDonation; public event Action<Profile, SubscriptionExtras>? OnSubscription; public event Action? OnClose; public event Action? OnOpen; public void RemoveAllOnMessageListener() { this.OnMessage = null; } public void RemoveAllOnDonationListener() { this.OnDonation = null; } public void RemoveAllOnSubscriptionListener() { this.OnSubscription = null; } public void Connect(string channelId) { StopListening("connect reset"); channel = ((channelId == null) ? string.Empty : channelId.Trim()); cancellation = new CancellationTokenSource(); receivedChatMessages = 0; heartbeatResponses = 0; lastFrameTicks = DateTime.UtcNow.Ticks; openEventRaised = false; connected = 0; ClearSendQueue(); CancellationToken token = cancellation.Token; LogInfo($"Starting CHZZK chat worker #{instanceId}."); worker = StartBackgroundThread($"ChzzkWorker#{instanceId}", delegate { Run(channel, token); }); } public void StopListening(string reason = "manual") { CancellationTokenSource cancellationTokenSource = cancellation; cancellation = null; connected = 0; if (cancellationTokenSource != null && !cancellationTokenSource.IsCancellationRequested) { LogInfo("Stopping CHZZK chat client: " + reason); cancellationTokenSource.Cancel(); } CloseSocket(); } private void Run(string requestedChannel, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(requestedChannel)) { LogError("ChannelId is empty. Please set it in StreamChats.cfg."); connected = -1; return; } while (!cancellationToken.IsCancellationRequested) { try { connected = 0; openEventRaised = false; LogInfo("Fetching CHZZK live status for channel: " + requestedChannel); LiveStatus liveStatus = FetchJson<LiveStatus>("https://api.chzzk.naver.com/polling/v2/channels/" + Uri.EscapeDataString(requestedChannel) + "/live-status", cancellationToken); string text = liveStatus.content?.chatChannelId ?? string.Empty; if (string.IsNullOrEmpty(text)) { LogWarning("live-status did not include chatChannelId. Trying live-detail fallback."); LiveDetail liveDetail = FetchJson<LiveDetail>("https://api.chzzk.naver.com/service/v1/channels/" + Uri.EscapeDataString(requestedChannel) + "/live-detail", cancellationToken); text = liveDetail.content?.chatChannelId ?? string.Empty; } if (string.IsNullOrEmpty(text)) { string text2 = liveStatus.content?.status ?? "unknown"; throw new InvalidOperationException("Could not find chatChannelId for channel '" + requestedChannel + "'. Live status: " + text2 + "."); } LogInfo("Resolved CHZZK chatChannelId: " + text); AccessTokenResult accessTokenResult = FetchJson<AccessTokenResult>("https://comm-api.game.naver.com/nng_main/v1/chats/access-token?channelId=" + Uri.EscapeDataString(text) + "&chatType=STREAMING", cancellationToken); string text3 = accessTokenResult.content?.accessToken ?? string.Empty; if (string.IsNullOrEmpty(text3)) { throw new InvalidOperationException("Could not get CHZZK chat access token for chat channel '" + text + "'."); } LogInfo("Received CHZZK chat access token."); RunSocket(text, text3, cancellationToken); } catch (OperationCanceledException) { break; } catch (Exception ex2) { connected = -1; LogError("CHZZK chat connection failed: " + ex2.Message); } if (cancellationToken.IsCancellationRequested) { break; } LogWarning($"Reconnecting to CHZZK chat in {5} seconds."); if (cancellationToken.WaitHandle.WaitOne(5000)) { break; } } } private void RunSocket(string cid, string accessToken, CancellationToken cancellationToken) { //IL_0074: Unknown result type (might be due to invalid IL or missing references) //IL_0079: Unknown result type (might be due to invalid IL or missing references) //IL_0093: Expected O, but got Unknown //IL_0159: Unknown result type (might be due to invalid IL or missing references) //IL_015f: Invalid comparison between Unknown and I4 //IL_0176: Unknown result type (might be due to invalid IL or missing references) string cid2 = cid; string accessToken2 = accessToken; string text = $"wss://kr-ss{SelectChatServerNumber(cid2)}.chat.naver.com/chat"; TaskCompletionSource<bool> closed = new TaskCompletionSource<bool>(); CancellationTokenSource heartbeatCancellation = null; CancellationTokenSource watchdogCancellation = null; CancellationTokenSource senderCancellation = null; WebSocket localSocket = null; try { ClearSendQueue(); localSocket = new WebSocket(text, Array.Empty<string>()) { WaitTime = TimeSpan.FromSeconds(60.0) }; SslProtocols enabledSslProtocols = SslProtocols.Tls | SslProtocols.Tls11 | SslProtocols.Tls12; localSocket.SslConfiguration.EnabledSslProtocols = enabledSslProtocols; localSocket.OnOpen += delegate { connected = 1; lastFrameTicks = DateTime.UtcNow.Ticks; LogInfo("CHZZK websocket opened. Sending read-only connect payload."); heartbeatCancellation = CancellationTokenSource.CreateLinkedTokenSource(new CancellationToken[1] { cancellationToken }); watchdogCancellation = CancellationTokenSource.CreateLinkedTokenSource(new CancellationToken[1] { cancellationToken }); senderCancellation = CancellationTokenSource.CreateLinkedTokenSource(new CancellationToken[1] { cancellationToken }); QueueSocketMessage(BuildConnectPayload(cid2, accessToken2)); StartBackgroundThread("ChzzkSender", delegate { SenderLoop(localSocket, senderCancellation.Token); }); StartBackgroundThread("ChzzkHeartbeat", delegate { HeartbeatLoop(localSocket, heartbeatCancellation.Token); }); StartBackgroundThread("ChzzkWatchdog", delegate { WatchdogLoop(localSocket, watchdogCancellation.Token); }); }; localSocket.OnMessage += delegate(object sender, MessageEventArgs args) { ParseMessage(localSocket, args); }; localSocket.OnClose += delegate(object sender, CloseEventArgs args) { bool flag = connected == 1; connected = -1; heartbeatCancellation?.Cancel(); watchdogCancellation?.Cancel(); senderCancellation?.Cancel(); if (!cancellationToken.IsCancellationRequested) { LogWarning($"CHZZK websocket closed: {args.Code} {args.Reason}"); if (flag) { MainThreadDispatcher.Enqueue(delegate { this.OnClose?.Invoke(); }); } } closed.TrySetResult(result: true); }; localSocket.OnError += delegate(object sender, ErrorEventArgs args) { if (!cancellationToken.IsCancellationRequested) { LogError("CHZZK websocket error: " + args.Message); } connected = -1; heartbeatCancellation?.Cancel(); watchdogCancellation?.Cancel(); senderCancellation?.Cancel(); closed.TrySetException(new InvalidOperationException(args.Message)); }; Interlocked.Exchange(ref socket, localSocket); LogInfo("Connecting to CHZZK chat: " + text); using (cancellationToken.Register(delegate { heartbeatCancellation?.Cancel(); watchdogCancellation?.Cancel(); senderCancellation?.Cancel(); CloseSocket(localSocket); closed.TrySetCanceled(); })) { localSocket.Connect(); if ((int)localSocket.ReadyState != 1) { throw new InvalidOperationException($"WebSocket connect failed: {localSocket.ReadyState}"); } closed.Task.GetAwaiter().GetResult(); } } finally { heartbeatCancellation?.Cancel(); heartbeatCancellation?.Dispose(); watchdogCancellation?.Cancel(); watchdogCancellation?.Dispose(); senderCancellation?.Cancel(); senderCancellation?.Dispose(); if (localSocket != null) { Interlocked.CompareExchange(ref socket, null, localSocket); CloseSocket(localSocket); } } } private static T FetchJson<T>(string url, CancellationToken cancellationToken) where T : class { cancellationToken.ThrowIfCancellationRequested(); LogInfo("HTTP GET " + url); try { ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12; HttpWebRequest httpWebRequest = (HttpWebRequest)WebRequest.Create(url); httpWebRequest.Method = "GET"; httpWebRequest.Accept = "application/json, text/plain, */*"; httpWebRequest.UserAgent = "Mozilla/5.0 StreamChats-LethalCompany"; httpWebRequest.Timeout = 15000; httpWebRequest.ReadWriteTimeout = 15000; httpWebRequest.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate; HttpStatusCode statusCode; string text; using (cancellationToken.Register(httpWebRequest.Abort)) { using HttpWebResponse httpWebResponse = (HttpWebResponse)httpWebRequest.GetResponse(); statusCode = httpWebResponse.StatusCode; text = ReadResponseBody(httpWebResponse); } LogInfo($"HTTP OK {(int)statusCode} {url}"); T val = JsonConvert.DeserializeObject<T>(text); if (val == null) { throw new InvalidOperationException("Empty response from " + url); } return val; } catch (WebException ex) { if (cancellationToken.IsCancellationRequested) { throw new OperationCanceledException(cancellationToken); } string arg = string.Empty; if (ex.Response != null) { using WebResponse response = ex.Response; arg = ReadResponseBody(response); } throw new InvalidOperationException($"HTTP request failed: {ex.Status} {ex.Message} {arg}"); } } private static string ReadResponseBody(WebResponse response) { using Stream stream = response.GetResponseStream(); if (stream == null) { return string.Empty; } using StreamReader streamReader = new StreamReader(stream); return streamReader.ReadToEnd(); } private void HeartbeatLoop(WebSocket targetSocket, CancellationToken cancellationToken) { //IL_00d3: Unknown result type (might be due to invalid IL or missing references) //IL_0096: Unknown result type (might be due to invalid IL or missing references) //IL_009c: Invalid comparison between Unknown and I4 //IL_003a: Unknown result type (might be due to invalid IL or missing references) //IL_0040: Invalid comparison between Unknown and I4 try { while (!cancellationToken.IsCancellationRequested && (int)targetSocket.ReadyState == 1) { TimeSpan timeSpan = TimeSpan.FromTicks(DateTime.UtcNow.Ticks - Interlocked.Read(ref lastFrameTicks)); if (timeSpan.TotalMilliseconds >= 20000.0 && (int)targetSocket.ReadyState == 1) { QueueSocketMessage("{\"ver\":\"2\",\"cmd\":0}"); LogInfo($"Queued CHZZK heartbeat ping after {timeSpan.TotalSeconds:0}s without a frame."); } if (cancellationToken.WaitHandle.WaitOne(2000)) { break; } } } catch (Exception ex) { LogError("CHZZK heartbeat failed: " + ex.Message); } finally { LogInfo($"CHZZK heartbeat loop stopped. socketState={targetSocket.ReadyState}, cancelled={cancellationToken.IsCancellationRequested}"); } } private void WatchdogLoop(WebSocket targetSocket, CancellationToken cancellationToken) { //IL_00ff: Unknown result type (might be due to invalid IL or missing references) //IL_00c2: Unknown result type (might be due to invalid IL or missing references) //IL_00c8: Invalid comparison between Unknown and I4 int num = 0; try { while (!cancellationToken.IsCancellationRequested && (int)targetSocket.ReadyState == 1 && !cancellationToken.WaitHandle.WaitOne(3000)) { num++; TimeSpan timeSpan = TimeSpan.FromTicks(DateTime.UtcNow.Ticks - Interlocked.Read(ref lastFrameTicks)); if (num % 5 == 0) { LogInfo($"Watchdog alive #{num}, last frame {timeSpan.TotalSeconds:0}s ago."); } if (timeSpan.TotalMilliseconds < 30000.0) { continue; } LogWarning($"No CHZZK websocket frame for {timeSpan.TotalSeconds:0}s. Forcing reconnect."); CloseSocket(targetSocket); break; } } catch (Exception ex) { LogError("CHZZK watchdog failed: " + ex.Message); } finally { LogInfo($"CHZZK watchdog loop stopped. socketState={targetSocket.ReadyState}, cancelled={cancellationToken.IsCancellationRequested}"); } } private void ParseMessage(WebSocket targetSocket, MessageEventArgs e) { try { Interlocked.Exchange(ref lastFrameTicks, DateTime.UtcNow.Ticks); JObject val = JObject.Parse(e.Data); switch (((JToken)val).Value<int?>((object)"cmd").GetValueOrDefault(-1)) { case 0: QueueSocketMessage("{\"ver\":\"2\",\"cmd\":10000}"); break; case 93101: { JToken obj2 = val["bdy"]; HandleChat((JArray?)(object)((obj2 is JArray) ? obj2 : null)); break; } case 93102: { JToken obj = val["bdy"]; HandleSpecialMessage((JArray?)(object)((obj is JArray) ? obj : null)); break; } case 10000: heartbeatResponses++; LogInfo($"Received CHZZK heartbeat pong #{heartbeatResponses}."); break; case 10100: if (!openEventRaised) { openEventRaised = true; LogInfo("Connected to CHZZK chat."); MainThreadDispatcher.Enqueue(delegate { this.OnOpen?.Invoke(); }); } break; } } catch (Exception arg) { LogError($"Failed to parse CHZZK websocket message: {arg}"); } } private void HandleChat(JArray? body) { if (body == null) { return; } foreach (JToken item in body) { JObject val = (JObject)(object)((item is JObject) ? item : null); if (val == null) { continue; } Profile profile = ParseJsonPayload<Profile>(val["profile"]) ?? Profile.Unknown(); string text = ((JToken)val).Value<string>((object)"msg")?.Trim() ?? string.Empty; if (text.Length != 0) { receivedChatMessages++; LogInfo($"Received CHZZK chat #{receivedChatMessages}: {profile.nickname}: {text}"); Profile capturedProfile = profile; string capturedMessage = text; MainThreadDispatcher.Enqueue(delegate { this.OnMessage?.Invoke(capturedProfile, capturedMessage); }); } } } private void HandleSpecialMessage(JArray? body) { if (body == null) { return; } foreach (JToken item in body) { JObject val = (JObject)(object)((item is JObject) ? item : null); if (val == null || !int.TryParse(((JToken)val).Value<string>((object)"msgTypeCode"), out var result)) { continue; } Profile profile = ParseJsonPayload<Profile>(val["profile"]); string message = ((JToken)val).Value<string>((object)"msg") ?? string.Empty; JToken token = val["extra"] ?? val["extras"]; switch (result) { case 10: { DonationExtras donation = ParseJsonPayload<DonationExtras>(token) ?? new DonationExtras(); Profile donationProfile = profile ?? Profile.FromNickname(donation.isAnonymous ? "익명" : donation.nickname); MainThreadDispatcher.Enqueue(delegate { this.OnDonation?.Invoke(donationProfile, message, donation); }); break; } case 11: { SubscriptionExtras subscription = ParseJsonPayload<SubscriptionExtras>(token) ?? new SubscriptionExtras(); Profile subscriptionProfile = profile ?? Profile.FromNickname(subscription.nickname); MainThreadDispatcher.Enqueue(delegate { this.OnSubscription?.Invoke(subscriptionProfile, subscription); }); break; } default: LogInfo($"Unsupported CHZZK message type: {result}"); break; } } } private static T? ParseJsonPayload<T>(JToken? token) where T : class { //IL_0005: Unknown result type (might be due to invalid IL or missing references) //IL_000c: Invalid comparison between Unknown and I4 //IL_0023: Unknown result type (might be due to invalid IL or missing references) //IL_0029: Invalid comparison between Unknown and I4 if (token == null || (int)token.Type == 10) { return null; } string text = (((int)token.Type == 8) ? Extensions.Value<string>((IEnumerable<JToken>)token) : token.ToString((Formatting)0, Array.Empty<JsonConverter>())); if (string.IsNullOrWhiteSpace(text) || text == "null") { return null; } try { return JsonConvert.DeserializeObject<T>(text); } catch (Exception ex) { LogError("CHZZK JSON payload parse failed: " + ex.Message); return null; } } private static string BuildConnectPayload(string cid, string accessToken) { //IL_0001: Unknown result type (might be due to invalid IL or missing references) //IL_0006: Unknown result type (might be due to invalid IL or missing references) //IL_001c: Unknown result type (might be due to invalid IL or missing references) //IL_002f: Unknown result type (might be due to invalid IL or missing references) //IL_0045: Unknown result type (might be due to invalid IL or missing references) //IL_0057: Unknown result type (might be due to invalid IL or missing references) //IL_005d: Unknown result type (might be due to invalid IL or missing references) //IL_0062: Unknown result type (might be due to invalid IL or missing references) //IL_0073: Unknown result type (might be due to invalid IL or missing references) //IL_0089: Unknown result type (might be due to invalid IL or missing references) //IL_009b: Unknown result type (might be due to invalid IL or missing references) //IL_00b6: Expected O, but got Unknown //IL_00b7: Unknown result type (might be due to invalid IL or missing references) return ((JToken)new JObject { ["ver"] = JToken.op_Implicit("2"), ["cmd"] = JToken.op_Implicit(100), ["svcid"] = JToken.op_Implicit("game"), ["cid"] = JToken.op_Implicit(cid), ["bdy"] = (JToken)new JObject { ["uid"] = (JToken)(object)JValue.CreateNull(), ["devType"] = JToken.op_Implicit(2001), ["accTkn"] = JToken.op_Implicit(accessToken), ["auth"] = JToken.op_Implicit("READ") }, ["tid"] = JToken.op_Implicit(1) }).ToString((Formatting)0, Array.Empty<JsonConverter>()); } private void QueueSocketMessage(string message) { sendQueue.Enqueue(message); } private void SenderLoop(WebSocket targetSocket, CancellationToken cancellationToken) { //IL_009a: Unknown result type (might be due to invalid IL or missing references) //IL_0054: Unknown result type (might be due to invalid IL or missing references) //IL_005a: Invalid comparison between Unknown and I4 //IL_002c: Unknown result type (might be due to invalid IL or missing references) //IL_0032: Invalid comparison between Unknown and I4 try { while (!cancellationToken.IsCancellationRequested && (int)targetSocket.ReadyState == 1) { if (!sendQueue.TryDequeue(out string result)) { cancellationToken.WaitHandle.WaitOne(10); } else if ((int)targetSocket.ReadyState == 1) { targetSocket.Send(result); LogSocketSend(result); } } } catch (Exception ex) { LogError("CHZZK sender failed: " + ex.Message); connected = -1; CloseSocket(targetSocket); } finally { LogInfo($"CHZZK sender loop stopped. socketState={targetSocket.ReadyState}, cancelled={cancellationToken.IsCancellationRequested}"); } } private static void LogSocketSend(string message) { if (message.Contains("\"cmd\":100")) { LogInfo("Sent CHZZK connect payload."); } else if (message.Contains("\"cmd\":0")) { LogInfo("Sent CHZZK heartbeat ping."); } else if (message.Contains("\"cmd\":10000")) { LogInfo("Sent CHZZK server-ping pong."); } } private static Thread StartBackgroundThread(string name, Action action) { Action action2 = action; string name2 = name; Thread thread = new Thread((ThreadStart)delegate { try { action2(); } catch (Exception ex) { LogError(name2 + " thread crashed: " + ex.Message); } }) { Name = name2, IsBackground = true }; thread.Start(); LogInfo("Started background thread: " + name2); return thread; } private void CloseSocket() { WebSocket val = Interlocked.Exchange(ref socket, null); if (val != null) { CloseSocket(val); } } private static void CloseSocket(WebSocket targetSocket) { //IL_0003: Unknown result type (might be due to invalid IL or missing references) //IL_0009: Invalid comparison between Unknown and I4 //IL_000c: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Invalid comparison between Unknown and I4 try { if ((int)targetSocket.ReadyState == 1 || (int)targetSocket.ReadyState == 0) { targetSocket.Close(); } } catch (Exception ex) { LogWarning("Failed to close CHZZK websocket cleanly: " + ex.Message); } } private void ClearSendQueue() { string result; while (sendQueue.TryDequeue(out result)) { } } private static int SelectChatServerNumber(string chatChannelId) { int num = 0; foreach (char c in chatChannelId) { num += c; } return Math.Abs(num) % 9 + 1; } private static void LogInfo(string message) { ManualLogSource? logger = Plugin.logger; if (logger != null) { logger.LogInfo((object)message); } } private static void LogWarning(string message) { ManualLogSource? logger = Plugin.logger; if (logger != null) { logger.LogWarning((object)message); } } private static void LogError(string message) { ManualLogSource? logger = Plugin.logger; if (logger != null) { logger.LogError((object)message); } } } internal sealed class TwitchChatClient { private const string Server = "irc.chat.twitch.tv"; private const int Port = 6667; private static readonly Random Random = new Random(); private CancellationTokenSource? cancellation; private Thread? worker; private TcpClient? client; private StreamWriter? writer; public event Action<string, string>? OnMessage; public void Connect(string channelHandle) { StopListening("connect reset"); string channel = NormalizeChannel(channelHandle); if (string.IsNullOrWhiteSpace(channel)) { LogError("TwitchHandle is empty."); return; } cancellation = new CancellationTokenSource(); CancellationToken token = cancellation.Token; worker = StartBackgroundThread("TwitchChatWorker", delegate { Run(channel, token); }); } public void StopListening(string reason = "manual") { CancellationTokenSource cancellationTokenSource = cancellation; cancellation = null; if (cancellationTokenSource != null && !cancellationTokenSource.IsCancellationRequested) { LogInfo("Stopping Twitch chat client: " + reason); cancellationTokenSource.Cancel(); } CloseSocket(); } private void Run(string channel, CancellationToken cancellationToken) { while (!cancellationToken.IsCancellationRequested) { TcpClient tcpClient = null; StreamReader streamReader = null; StreamWriter streamWriter = null; try { LogInfo(string.Format("Connecting to Twitch IRC over TCP {0}:{1}: #{2}", "irc.chat.twitch.tv", 6667, channel)); tcpClient = new TcpClient(); tcpClient.Connect("irc.chat.twitch.tv", 6667); tcpClient.ReceiveTimeout = 1000; tcpClient.SendTimeout = 5000; NetworkStream stream = tcpClient.GetStream(); streamReader = new StreamReader(stream); streamWriter = new StreamWriter(stream) { NewLine = "\r\n", AutoFlush = true }; client = tcpClient; writer = streamWriter; string text = NextAnonymousNick(); LogInfo("Connected to Twitch IRC as " + text + ". Joining #" + channel + "."); SendIrc(streamWriter, "PASS SCHMOOPIIE"); SendIrc(streamWriter, "NICK " + text); SendIrc(streamWriter, "CAP REQ :twitch.tv/tags twitch.tv/commands"); SendIrc(streamWriter, "JOIN #" + channel); while (!cancellationToken.IsCancellationRequested && tcpClient.Connected) { string text2; try { text2 = streamReader.ReadLine(); } catch (IOException) { continue; } if (text2 == null) { throw new IOException("Twitch IRC connection closed."); } HandleIrcLine(streamWriter, text2); } } catch (Exception ex2) { if (!cancellationToken.IsCancellationRequested) { LogError("Twitch chat failed: " + ex2.Message); } } finally { streamReader?.Dispose(); streamWriter?.Dispose(); tcpClient?.Close(); if (client == tcpClient) { client = null; writer = null; } } if (cancellationToken.IsCancellationRequested) { break; } LogWarning("Reconnecting to Twitch chat in 15 seconds."); if (cancellationToken.WaitHandle.WaitOne(15000)) { break; } } } private void HandleIrcLine(StreamWriter targetWriter, string line) { line = line.Trim(); if (line.Length == 0) { return; } if (line.StartsWith("PING", StringComparison.OrdinalIgnoreCase)) { SendIrc(targetWriter, "PONG :tmi.twitch.tv"); return; } string input = (line.StartsWith("@", StringComparison.Ordinal) ? line.Substring(line.IndexOf(' ') + 1) : line); Match match = Regex.Match(input, ":([^!]+)![^ ]+ PRIVMSG #[^ ]+ :(.+)$"); if (match.Success) { string name = match.Groups[1].Value; string message = match.Groups[2].Value; LogInfo("Received Twitch chat: " + name + ": " + message); MainThreadDispatcher.Enqueue(delegate { this.OnMessage?.Invoke(name, message); }); } } private static void SendIrc(StreamWriter targetWriter, string message) { targetWriter.WriteLine(message); } private static string NormalizeChannel(string channelHandle) { string text = channelHandle.Trim(); if (text.StartsWith("@", StringComparison.Ordinal)) { text = text.Substring(1); } if (Uri.TryCreate(text, UriKind.Absolute, out Uri result)) { text = result.AbsolutePath.Trim('/'); } return text.Trim().TrimStart('#').ToLowerInvariant(); } private static string NextAnonymousNick() { lock (Random) { return $"justinfan{Random.Next(10000, 999999)}"; } } private static Thread StartBackgroundThread(string name, Action action) { Action action2 = action; string name2 = name; Thread thread = new Thread((ThreadStart)delegate { try { action2(); } catch (Exception ex) { LogError(name2 + " crashed: " + ex.Message); } }) { Name = name2, IsBackground = true }; thread.Start(); LogInfo("Started background thread: " + name2); return thread; } private void CloseSocket() { StreamWriter streamWriter = writer; TcpClient tcpClient = client; writer = null; client = null; try { streamWriter?.Dispose(); } catch (Exception ex) { LogWarning("Failed to close Twitch IRC writer cleanly: " + ex.Message); } try { tcpClient?.Close(); } catch (Exception ex2) { LogWarning("Failed to close Twitch IRC client cleanly: " + ex2.Message); } } private static void LogInfo(string message) { ManualLogSource? logger = Plugin.logger; if (logger != null) { logger.LogInfo((object)message); } } private static void LogWarning(string message) { ManualLogSource? logger = Plugin.logger; if (logger != null) { logger.LogWarning((object)message); } } private static void LogError(string message) { ManualLogSource? logger = Plugin.logger; if (logger != null) { logger.LogError((object)message); } } } internal sealed class YoutubeLiveChatClient { private readonly struct ResponseText { public string Body { get; } public Uri? ResponseUri { get; } public ResponseText(string body, Uri? responseUri) { Body = body; ResponseUri = responseUri; } } private const int HttpRequestTimeoutMilliseconds = 15000; private readonly HashSet<string> seenMessageIds = new HashSet<string>(); private CancellationTokenSource? cancellation; private Thread? worker; public event Action<string, string>? OnMessage; public void Connect(string channelHandle) { StopListening("connect reset"); string target = ((channelHandle == null) ? string.Empty : channelHandle.Trim()); if (string.IsNullOrWhiteSpace(target)) { LogError("YoutubeHandle is empty."); return; } cancellation = new CancellationTokenSource(); CancellationToken token = cancellation.Token; worker = StartBackgroundThread("YoutubeChatWorker", delegate { Run(target, token); }); } public void StopListening(string reason = "manual") { CancellationTokenSource cancellationTokenSource = cancellation; cancellation = null; if (cancellationTokenSource != null && !cancellationTokenSource.IsCancellationRequested) { LogInfo("Stopping YouTube chat client: " + reason); cancellationTokenSource.Cancel(); } } private void Run(string target, CancellationToken cancellationToken) { while (!cancellationToken.IsCancellationRequested) { try { seenMessageIds.Clear(); ResponseText responseText = FetchLivePage(target, cancellationToken); string text = ExtractVideoId(responseText.ResponseUri, responseText.Body); if (string.IsNullOrWhiteSpace(text)) { throw new InvalidOperationException("Could not find a live YouTube video for the configured handle."); } LogInfo("Resolved YouTube live video: " + text); ResponseText @string = GetString("https://www.youtube.com/watch?v=" + Uri.EscapeDataString(text), cancellationToken); string apiKey = ExtractRequired(@string.Body, "\"INNERTUBE_API_KEY\"\\s*:\\s*\"([^\"]+)\"", "INNERTUBE_API_KEY"); string clientVersion = ExtractOptional(@string.Body, "\"clientVersion\"\\s*:\\s*\"([^\"]+)\"") ?? "2.20240501.00.00"; string text2 = ExtractLiveChatContinuation(@string.Body); if (string.IsNullOrWhiteSpace(text2)) { throw new InvalidOperationException("Could not find YouTube live chat continuation."); } LogInfo("Connected to YouTube live chat polling."); PollLiveChat(apiKey, clientVersion, text2, cancellationToken); } catch (OperationCanceledException) { break; } catch (Exception ex2) { if (!cancellationToken.IsCancellationRequested) { LogError("YouTube chat failed: " + ex2.Message); } } if (cancellationToken.IsCancellationRequested) { break; } LogWarning("Reconnecting to YouTube chat in 10 seconds."); if (cancellationToken.WaitHandle.WaitOne(10000)) { break; } } } private void PollLiveChat(string apiKey, string clientVersion, string continuation, CancellationToken cancellationToken) { //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_000e: Unknown result type (might be due to invalid IL or missing references) //IL_0014: Unknown result type (might be due to invalid IL or missing references) //IL_0019: Unknown result type (might be due to invalid IL or missing references) //IL_001f: Unknown result type (might be due to invalid IL or missing references) //IL_0024: Unknown result type (might be due to invalid IL or missing references) //IL_003a: Unknown result type (might be due to invalid IL or missing references) //IL_0051: Expected O, but got Unknown //IL_0057: Expected O, but got Unknown //IL_0058: Unknown result type (might be due to invalid IL or missing references) //IL_006b: Expected O, but got Unknown string text = continuation; while (!cancellationToken.IsCancellationRequested && !string.IsNullOrWhiteSpace(text)) { JObject val = new JObject { ["context"] = (JToken)new JObject { ["client"] = (JToken)new JObject { ["clientName"] = JToken.op_Implicit("WEB"), ["clientVersion"] = JToken.op_Implicit(clientVersion) } }, ["continuation"] = JToken.op_Implicit(text) }; string text2 = PostJson("https://www.youtube.com/youtubei/v1/live_chat/get_live_chat?key=" + Uri.EscapeDataString(apiKey), ((JToken)val).ToString((Formatting)0, Array.Empty<JsonConverter>()), cancellationToken); JObject root = JObject.Parse(text2); EmitChatMessages(root); int timeoutMs; string text3 = FindContinuation(root, out timeoutMs); if (string.IsNullOrWhiteSpace(text3)) { throw new InvalidOperationException("YouTube live chat response did not include a continuation."); } text = text3; int millisecondsTimeout = Math.Max(1000, Math.Min((timeoutMs <= 0) ? 3000 : timeoutMs, 10000)); cancellationToken.WaitHandle.WaitOne(millisecondsTimeout); } } private void EmitChatMessages(JObject root) { foreach (JToken item in ((JToken)root).SelectTokens("$..liveChatTextMessageRenderer")) { string text = item.Value<string>((object)"id") ?? string.Empty; if (text.Length > 0 && !seenMessageIds.Add(text)) { continue; } string name = ReadRunsOrSimpleText(item[(object)"authorName"]) ?? "YouTube"; string message = ReadRunsOrSimpleText(item[(object)"message"]) ?? string.Empty; if (message.Length != 0) { LogInfo("Received YouTube chat: " + name + ": " + message); MainThreadDispatcher.Enqueue(delegate { this.OnMessage?.Invoke(name, message); }); } } } private static string? FindContinuation(JObject root, out int timeoutMs) { timeoutMs = 3000; foreach (JToken item in ((JToken)root).SelectTokens("$..timedContinuationData")) { string text = item.Value<string>((object)"continuation"); timeoutMs = item.Value<int?>((object)"timeoutMs") ?? timeoutMs; if (!string.IsNullOrWhiteSpace(text)) { return text; } } foreach (JToken item2 in ((JToken)root).SelectTokens("$..invalidationContinuationData")) { string text2 = item2.Value<string>((object)"continuation"); timeoutMs = item2.Value<int?>((object)"timeoutMs") ?? timeoutMs; if (!string.IsNullOrWhiteSpace(text2)) { return text2; } } return null; } private static ResponseText FetchLivePage(string target, CancellationToken cancellationToken) { string text = NormalizeTargetUrl(target); LogInfo("Fetching YouTube live page: " + text); return GetString(text, cancellationToken); } private static string NormalizeTargetUrl(string target) { if (Uri.TryCreate(target, UriKind.Absolute, out Uri _)) { return target; } string stringToEscape = target.Trim().TrimStart('@'); return "https://www.youtube.com/@" + Uri.EscapeDataString(stringToEscape) + "/live"; } private static string? ExtractVideoId(Uri? responseUri, string body) { if (responseUri != null) { Match match = Regex.Match(responseUri.Query, "(?:^|[?&])v=([A-Za-z0-9_-]{11})"); if (match.Success) { return match.Groups[1].Value; } Match match2 = Regex.Match(responseUri.AbsolutePath, "/(?:watch|live)/([A-Za-z0-9_-]{11})"); if (match2.Success) { return match2.Groups[1].Value; } } Match match3 = Regex.Match(body, "\"videoId\"\\s*:\\s*\"([A-Za-z0-9_-]{11})\""); return match3.Success ? match3.Groups[1].Value : null; } private static string? ExtractLiveChatContinuation(string body) { int num = body.IndexOf("liveChatRenderer", StringComparison.Ordinal); if (num < 0) { num = body.IndexOf("liveChat", StringComparison.Ordinal); } string input = ((num >= 0) ? body.Substring(num) : body); Match match = Regex.Match(input, "\"continuation\"\\s*:\\s*\"([^\"]+)\""); return match.Success ? Regex.Unescape(match.Groups[1].Value) : null; } private static string ExtractRequired(string body, string pattern, string name) { string text = ExtractOptional(body, pattern); if (string.IsNullOrWhiteSpace(text)) { throw new InvalidOperationException("Could not find YouTube " + name + "."); } return text; } private static string? ExtractOptional(string body, string pattern) { Match match = Regex.Match(body, pattern); return match.Success ? Regex.Unescape(match.Groups[1].Value) : null; } private static string? ReadRunsOrSimpleText(JToken? token) { //IL_0005: Unknown result type (might be due to invalid IL or missing references) //IL_000c: Invalid comparison between Unknown and I4 if (token == null || (int)token.Type == 10) { return null; } string value = token.Value<string>((object)"simpleText"); if (!string.IsNullOrEmpty(value)) { return WebUtility.HtmlDecode(value); } StringBuilder stringBuilder = new StringBuilder(); JToken obj = token[(object)"runs"]; JArray val = (JArray)(object)((obj is JArray) ? obj : null); if (val == null) { return null; } foreach (JToken item in val) { string text = item.Value<string>((object)"text"); if (text != null) { stringBuilder.Append(text); continue; } JToken obj2 = item[(object)"emoji"]; object obj3; if (obj2 == null) { obj3 = null; } else { JToken obj4 = obj2[(object)"shortcuts"]; if (obj4 == null) { obj3 = null; } else { JToken obj5 = obj4[(object)0]; obj3 = ((obj5 != null) ? Extensions.Value<string>((IEnumerable<JToken>)obj5) : null); } } if (obj3 == null) { JToken obj6 = item[(object)"emoji"]; if (obj6 == null) { obj3 = null; } else { JToken obj7 = obj6[(object)"image"]; if (obj7 == null) { obj3 = null; } else { JToken obj8 = obj7[(object)"accessibility"]; if (obj8 == null) { obj3 = null; } else { JToken obj9 = obj8[(object)"accessibilityData"]; obj3 = ((obj9 != null) ? obj9.Value<string>((object)"label") : null); } } } } string value2 = (string)obj3; if (!string.IsNullOrEmpty(value2)) { stringBuilder.Append(value2); } } return WebUtility.HtmlDecode(stringBuilder.ToString()); } private static ResponseText GetString(string url, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12; HttpWebRequest httpWebRequest = (HttpWebRequest)WebRequest.Create(url); httpWebRequest.Method = "GET"; httpWebRequest.Accept = "text/html,application/json,*/*"; httpWebRequest.UserAgent = "Mozilla/5.0 StreamChats-LethalCompany"; httpWebRequest.Timeout = 15000; httpWebRequest.ReadWriteTimeout = 15000; httpWebRequest.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate; httpWebRequest.AllowAutoRedirect = true; using (cancellationToken.Register(httpWebRequest.Abort)) { using HttpWebResponse httpWebResponse = (HttpWebResponse)httpWebRequest.GetResponse(); using Stream stream = httpWebResponse.GetResponseStream(); using StreamReader streamReader = new StreamReader(stream ?? Stream.Null); return new ResponseText(streamReader.ReadToEnd(), httpWebResponse.ResponseUri); } } private static string PostJson(string url, string body, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12; byte[] bytes = Encoding.UTF8.GetBytes(body); HttpWebRequest httpWebRequest = (HttpWebRequest)WebRequest.Create(url); httpWebRequest.Method = "POST"; httpWebRequest.Accept = "application/json"; httpWebRequest.ContentType = "application/json"; httpWebRequest.UserAgent = "Mozilla/5.0 StreamChats-LethalCompany"; httpWebRequest.Timeout = 15000; httpWebRequest.ReadWriteTimeout = 15000; httpWebRequest.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate; using (cancellationToken.Register(httpWebRequest.Abort)) { using Stream stream = httpWebRequest.GetRequestStream(); stream.Write(bytes, 0, bytes.Length); } using HttpWebResponse httpWebResponse = (HttpWebResponse)httpWebRequest.GetResponse(); using Stream stream2 = httpWebResponse.GetResponseStream(); using StreamReader streamReader = new StreamReader(stream2 ?? Stream.Null); return streamReader.ReadToEnd(); } private static Thread StartBackgroundThread(string name, Action action) { Action action2 = action; string name2 = name; Thread thread = new Thread((ThreadStart)delegate { try { action2(); } catch (Exception ex) { LogError(name2 + " crashed: " + ex.Message); } }) { Name = name2, IsBackground = true }; thread.Start(); LogInfo("Started background thread: " + name2); return thread; } private static void LogInfo(string message) { ManualLogSource? logger = Plugin.logger; if (logger != null) { logger.LogInfo((object)message); } } private static void LogWarning(string message) { ManualLogSource? logger = Plugin.logger; if (logger != null) { logger.LogWarning((object)message); } } private static void LogError(string message) { ManualLogSource? logger = Plugin.logger; if (logger != null) { logger.LogError((object)message); } } } } namespace ChzzkChat.Configuration { internal static class Config { private static ConfigFile config; private static ConfigEntry<string> config_channel_id; private static ConfigEntry<string> twitch_handle; private static ConfigEntry<string> youtube_handle; private static ConfigEntry<bool> show_platform_prefix; public static string ConfigChannelId => config_channel_id.Value; public static string TwitchHandle => twitch_handle.Value; public static string YoutubeHandle => youtube_handle.Value; public static bool ShowPlatformPrefix => show_platform_prefix.Value; public static void Load() { //IL_0013: Unknown result type (might be due to invalid IL or missing references) //IL_001d: Expected O, but got Unknown string text = Path.Combine(Paths.ConfigPath, "StreamChats.cfg"); config = new ConfigFile(text, true); InternalLoad(); } public static void InternalLoad() { config_channel_id = config.Bind<string>("Access", "ChannelId", "", "CHZZK channel ID for chat"); twitch_handle = config.Bind<string>("Access", "TwitchHandle", "", "Twitch channel handle/login, without @"); youtube_handle = config.Bind<string>("Access", "YoutubeHandle", "", "YouTube channel handle, channel URL, or live video URL"); show_platform_prefix = config.Bind<bool>("Display", "ShowPlatformPrefix", true, "Show [CHZZK], [Twitch], or [YouTube] before chat names"); } } } namespace System.Runtime.CompilerServices { [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] internal sealed class IgnoresAccessChecksToAttribute : Attribute { public IgnoresAccessChecksToAttribute(string assemblyName) { } } }