using System; using System.Collections; using System.Collections.Generic; using System.Text.RegularExpressions; using UnityEngine; using UnityEngine.Events; using UnityEngine.Networking; using WebSocketSharp; using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace WefLab { /// /// Donation data structure /// [System.Serializable] public class DonationData { public string platform; // afreeca / naver(chzzk) / youtube / twitch / ... public string donationType; // weflab subtype (SENDBALLOON / chzzk / superchat / bits ...) public string subtype; // same as donationType, explicit name public int amount; // normalized amount in KRW public int rawValue; // raw platform value before KRW conversion (balloon count, cheese raw, bits, ...) public string message; public string donorName; public string donorId; public long timestamp; } /// /// A single emoticon/sticker referenced in a chat message. /// [System.Serializable] public class ChatEmoticon { public string name; // emoticon key/name as it appears in the message public string imageUrl; // image URL to render } /// /// Chat message data structure. Shares weflab's flat envelope with donations /// (type/data) but carries chat-specific fields: color, badges, emoticons, warnings. /// [System.Serializable] public class ChatData { public string platform; public string type; // "chat" (normal) or "warn" (connection warning) public string subtype; // platform-specific subtype (e.g. "emoticon") public string nickname; // display name (data.name, falls back to data.id) public string userId; // data.id public string message; // data.msg (may contain emoticon name placeholders) public string nickColor; // data.color (hex, may be empty) // Viewer grade flags (from data.grade) public bool isStreamer; public bool isManager; public bool isFan; public bool isSubscriber; public bool isVip; public string[] badges; // data.grade.badge (platform badge ids) public List emoticons = new List(); // data.emoticon // Connection warning (type == "warn"): not a real chat message public bool isWarn; public string warnType; // subtype when isWarn (e.g. "password", "subonly", "donationoff") public long timestamp; } /// /// Unity event with donation data parameter /// [System.Serializable] public class DonationEvent : UnityEvent { } /// /// Unity event with chat data parameter /// [System.Serializable] public class ChatEvent : UnityEvent { } /// /// Donation event trigger based on amount range /// [System.Serializable] public class DonationEventTrigger { [Tooltip("Name for this donation trigger (e.g., 'Small Donation', 'Large Donation')")] public string triggerName = "New Trigger"; [Header("Amount Range")] [Tooltip("Minimum donation amount (inclusive)")] public int minAmount = 0; [Tooltip("Maximum donation amount (inclusive, -1 for unlimited)")] public int maxAmount = -1; [Header("Repeat Settings")] [Tooltip("Use amount-based repeat count (divide amount by amountPerRepeat)")] public bool useAmountBasedRepeat = false; [Tooltip("Amount of KRW per repeat (e.g., 1000 = 1 repeat per 1000 KRW)")] [Min(1)] public int amountPerRepeat = 1000; [Tooltip("Minimum repeat count")] [Min(1)] public int minRepeatCount = 1; [Tooltip("Maximum repeat count (0 = unlimited)")] [Min(0)] public int maxRepeatCount = 20; [Tooltip("Fixed repeat count (used when useAmountBasedRepeat is false)")] [Min(1)] public int fixedRepeatCount = 1; [Tooltip("Delay between each repeat in seconds (0 for immediate)")] [Min(0)] public float repeatDelay = 0.15f; [Header("Event")] [Tooltip("Unity event to trigger when donation amount is in range")] public DonationEvent onDonation; /// /// Check if donation amount is within range /// public bool IsInRange(int amount) { if (amount < minAmount) return false; if (maxAmount >= 0 && amount > maxAmount) return false; return true; } /// /// Calculate repeat count based on donation amount /// public int GetRepeatCount(int amount) { if (!useAmountBasedRepeat) { return fixedRepeatCount; } // Calculate repeat count based on amount int count = amount / amountPerRepeat; // Apply min/max limits count = Mathf.Max(count, minRepeatCount); if (maxRepeatCount > 0) { count = Mathf.Min(count, maxRepeatCount); } return count; } } /// /// WefLab WebSocket client for receiving donation events /// Implements Engine.IO v4 and Socket.IO protocol /// public class WefLabWebSocketClient : MonoBehaviour { [Header("WefLab Settings")] [Tooltip("WefLab page URL (e.g., https://weflab.com/page/guPK2ODAmmRvYG4)")] public string pageUrl = "https://weflab.com/page/guPK2ODAmmRvYG4"; [Tooltip("Also connect to the main control socket (ssmain). Donations arrive on the per-platform sockets, so this is usually not needed.")] public bool connectMainSocket = false; [Tooltip("Drop duplicate donations with an identical signature within this window in seconds (guards against the same alert arriving on more than one socket). 0 disables.")] [Min(0f)] public float duplicateWindow = 3f; [Header("Donation Event Triggers")] [Tooltip("Donation event triggers based on amount ranges")] public DonationEventTrigger[] donationTriggers = Array.Empty(); [Header("Chat (optional - structured for later use)")] [Tooltip("Receive chat messages. Off by default; turn on when you want to consume chat via onChatReceived.")] public bool enableChat = false; [Tooltip("Fired for every incoming chat message (when enableChat is on)")] public ChatEvent onChatReceived; [Header("Queue Settings")] [Tooltip("Enable sequential processing of donations (one alert at a time, like the weflab overlay)")] public bool enableQueue = true; [Tooltip("Lead-in delay before the donation event fires after it leaves the queue (seconds). " + "Approximates the weflab overlay's intro/slide-in animation so the Unity reaction lands when the alert is revealed. " + "Tune this to match your weflab alert's appearance timing.")] [Min(0f)] public float alertLeadTime = 0.8f; [Tooltip("On-screen display time for each alert before the next one is dequeued (seconds). " + "Set this close to your weflab alert display duration so spacing between alerts matches the overlay.")] [Min(0.1f)] public float alertDelay = 3f; [Tooltip("Maximum queue size (0 = unlimited)")] [Min(0)] public int maxQueueSize = 50; [Tooltip("Fired immediately when a donation is received from the socket (before the queue/lead-in). " + "Use for instant side effects like running totals; the amount-range triggers fire later, synced to the alert reveal.")] public DonationEvent onDonationReceived; [Header("Precise Timing Sync (match the weflab overlay reveal)")] [Tooltip("Replicate the overlay timing: when an alert reaches the front of the queue, fetch the SAME TTS audio weflab uses, " + "measure its length, fire the donation event when the fetch completes (= overlay reveal moment), then hold for the audio length. " + "Automatically tracks the overlay's synthesis latency. Falls back to alertLeadTime/alertDelay if settings or audio are unavailable.")] public bool syncWithAlertAudio = true; [Tooltip("Small constant added on top of the auto-measured TTS fetch time, to compensate server TTS cache skew + audio decode. " + "The bulk of the reveal offset is measured automatically; fine-tune this against your live overlay (seconds).")] [Min(0f)] public float audioStartOffset = 0.15f; [Tooltip("Gap between one alert's audio ending and the next alert appearing (seconds). Matches the overlay's inter-alert pause (~1s).")] [Min(0f)] public float interAlertGap = 1f; [Tooltip("TTS language code sent to weflab's voice endpoint (affects the measured audio length).")] public string ttsLang = "ko"; [Header("Debug")] [Tooltip("Show detailed WefLab logs in the console (raw payloads, every socket event, ping/pong, handshakes, platform-feed keepalives, chat). " + "Off keeps the console clean - only donations, connection status and errors are logged.")] public bool verboseLog = false; /// Console log that only fires when verboseLog (detail logging) is enabled. private void VLog(string message) { if (verboseLog) UnityEngine.Debug.Log(message); } // Queue state (visible in Inspector for debugging) [Header("Queue Status (Read Only)")] [SerializeField] private int queueCount = 0; [SerializeField] private bool isProcessingQueue = false; // Donation queue private Queue donationQueue = new Queue(); private Coroutine queueProcessorCoroutine = null; private Coroutine platformKeepAliveCoroutine = null; // ---- Precise timing: meta extracted from loginData + settings fetched from /api/ ---- private string userDir = ""; private string userPreset = "0"; private string pageType = "page"; // loginData.type private string pageId = "alert"; // loginData.pageid private string verServer = ""; private string verSocket = ""; private string baseDomain = "https://weflab.com"; private string voiceUrlBase = ""; // absolute TTS endpoint (config.url.voiceurl) private bool settingsLoaded = false; private float settingPopupTimeCap = 60f; // item_alert_popuptime (seconds) - display upper bound private float ttsSpeed = 1f; // item_alert_popupsoundspeed private string ttsTemplate = "{닉네임}님 {종류} {개수}{개} 감사합니다!"; // setup_alert_text // Hidden connection info [HideInInspector] public bool isConnected = false; [HideInInspector] public string currentSid = ""; [HideInInspector] public string extractedUserIdx = ""; /// /// One WebSocket connection to a single weflab socket server (one per linked platform). /// private class PlatformConnection { public string platform; // "afreeca", "naver", "youtube", "main", ... public string platformId; // the platform account id (loginData.platform[x].id), required for join_platform public JObject platformData; // the full loginData.platform[x] object, sent in the "platform" feed message public string page; // subscription page: "alert" (donations) or "chat" public string wsUrl; // wss://ssafreeca.weflab.com/socket.io/?... public WebSocket ws; public string engineSid = ""; public string socketSid = ""; public bool connected = false; public bool platformStarted = false; // whether the initial "platform" start message was sent public bool IsPlatform => platform != "main"; public string Label => $"{platform}/{page}"; } // Active connections (one per linked platform socket) private readonly List connections = new List(); private string userIdx = ""; // Will be extracted from page (shared by every socket) private bool isExtracting = false; // One-time init guard: the page/config extraction runs only on the first enable; // later enables (after a disable) just re-open the already-built connections. private bool initialized = false; // Duplicate-donation guard: signature -> last seen Time.time private readonly Dictionary recentDonations = new Dictionary(); // Ping/Pong settings (informational, reported by server handshake) private float pingInterval = 30f; // Thread-safe action queue for main thread private Queue mainThreadActions = new Queue(); private object actionLock = new object(); void OnEnable() { if (!initialized) { // First activation: extract user idx + config from the page, then connect. initialized = true; StartCoroutine(ExtractUserIdxAndConnect()); } else { // Re-enabled after a disable: connections/settings are already built, // so just re-open the sockets. The keepalive + queue processor restart // themselves once the sockets reconnect (HandleSocketConnect). Debug.Log("[WefLab] Re-enabled - reconnecting sockets"); ConnectAll(); } } void OnDisable() { // OnDestroy is NOT called on SetActive(false), so close the sockets here - // otherwise the background WebSocket threads keep running while disabled. Disconnect(); // Unity auto-stops this component's coroutines on disable. Reset the queue // state they leave behind so processing restarts cleanly on re-enable // (otherwise isProcessingQueue can stay true and stall the queue forever). donationQueue.Clear(); queueCount = 0; isProcessingQueue = false; queueProcessorCoroutine = null; // Drop any background-thread messages queued but not yet drained by Update(), // so re-enabling doesn't flood Update() with stale alerts. lock (actionLock) { mainThreadActions.Clear(); } } void Update() { // Execute queued actions on main thread lock (actionLock) { while (mainThreadActions.Count > 0) { mainThreadActions.Dequeue()?.Invoke(); } } } void OnDestroy() { Disconnect(); } /// /// Extract user index from page URL and connect /// private IEnumerator ExtractUserIdxAndConnect() { if (string.IsNullOrEmpty(pageUrl)) { Debug.LogError("[WefLab] Page URL is empty! Please set the page URL in Inspector."); yield break; } isExtracting = true; Debug.Log($"[WefLab] Fetching page URL: {pageUrl}"); // Fetch the page HTML using (UnityWebRequest request = UnityWebRequest.Get(pageUrl)) { yield return request.SendWebRequest(); if (request.result != UnityWebRequest.Result.Success) { Debug.LogError($"[WefLab] Failed to fetch page: {request.error}"); isExtracting = false; yield break; } string htmlContent = request.downloadHandler.text; // Extract userIdx from JavaScript in the page // Look for pattern: loginData = {...} Match loginDataMatch = Regex.Match(htmlContent, @"loginData\s*=\s*(\{[^;]+\});", RegexOptions.Singleline); if (loginDataMatch.Success) { try { string loginDataJson = loginDataMatch.Groups[1].Value; var loginData = JsonConvert.DeserializeObject(loginDataJson); // Extract idx from loginData userIdx = loginData["idx"]?.ToString() ?? ""; if (!string.IsNullOrEmpty(userIdx)) { extractedUserIdx = userIdx; Debug.Log($"[WefLab] Successfully extracted userIdx: {userIdx}"); // Capture meta needed for the precise-timing settings fetch ParseLoginMeta(loginData); // Build the per-platform socket connection list from the same loginData BuildConnections(loginData); } else { Debug.LogError("[WefLab] userIdx not found in loginData"); } } catch (Exception ex) { Debug.LogError($"[WefLab] Error parsing loginData: {ex.Message}"); } } else { Debug.LogError("[WefLab] Could not find loginData in page HTML"); } } isExtracting = false; // Connect if we successfully built at least one socket connection if (connections.Count > 0) { ConnectAll(); // Fetch alert design settings (popuptime/tts template/speed) for precise timing if (syncWithAlertAudio) { yield return StartCoroutine(FetchAlertSettings()); } } else { Debug.LogError("[WefLab] Cannot connect - no socket connections were prepared (userIdx/config extraction failed)"); } } /// /// Capture the loginData fields needed to call the /api/ settings endpoint and the TTS endpoint. /// private void ParseLoginMeta(JObject loginData) { userDir = loginData["dir"]?.ToString() ?? ""; userPreset = loginData["preset"]?.ToString() ?? "0"; pageType = loginData["type"]?.ToString() ?? "page"; pageId = loginData["pageid"]?.ToString() ?? "alert"; var config = loginData["config"] as JObject; verServer = config?["ver"]?["server"]?.ToString() ?? ""; verSocket = config?["ver"]?["socket"]?.ToString() ?? ""; string domain = config?["domain"]?.ToString(); if (!string.IsNullOrEmpty(domain)) baseDomain = "https://" + domain; string voiceUrl = config?["url"]?["voiceurl"]?.ToString(); if (!string.IsNullOrEmpty(voiceUrl)) voiceUrlBase = voiceUrl.StartsWith("http") ? voiceUrl : baseDomain + voiceUrl; } /// /// Fetch the streamer's alert design settings from POST /api/ (type=page_load). /// Reads the display-time cap, TTS speed and TTS text template used for precise timing. /// private IEnumerator FetchAlertSettings() { if (string.IsNullOrEmpty(userIdx) || string.IsNullOrEmpty(userDir)) { Debug.LogWarning("[WefLab] Cannot fetch alert settings - missing idx/dir; precise timing will fall back to alertDelay"); yield break; } WWWForm form = new WWWForm(); form.AddField("type", pageType + "_load"); form.AddField("pagetype", pageType); form.AddField("idx", userIdx); form.AddField("pageid", pageId); form.AddField("preset", userPreset); form.AddField("dir", userDir); form.AddField("ver[server]", verServer); form.AddField("ver[socket]", verSocket); using (UnityWebRequest request = UnityWebRequest.Post(baseDomain + "/api/", form)) { request.SetRequestHeader("Referer", pageUrl); yield return request.SendWebRequest(); if (request.result != UnityWebRequest.Result.Success) { Debug.LogWarning($"[WefLab] Alert settings fetch failed ({request.error}); precise timing falls back to alertDelay"); yield break; } try { var root = JsonConvert.DeserializeObject(request.downloadHandler.text); var data = root?["data"] as JObject; if (data == null) { Debug.LogWarning("[WefLab] Alert settings response had no data; falling back to alertDelay"); yield break; } string popup = FindSetting(data, "item_alert_popuptime"); if (!string.IsNullOrEmpty(popup) && float.TryParse(popup, out float pt) && pt > 0f) settingPopupTimeCap = pt; string speed = FindSetting(data, "item_alert_popupsoundspeed"); if (!string.IsNullOrEmpty(speed) && float.TryParse(speed, out float sp) && sp > 0f) ttsSpeed = sp; string template = FindSetting(data, "setup_alert_text"); if (!string.IsNullOrEmpty(template)) ttsTemplate = template; settingsLoaded = true; Debug.Log($"[WefLab] Alert settings loaded - popuptimeCap:{settingPopupTimeCap}s, ttsSpeed:{ttsSpeed}, template:\"{ttsTemplate}\", voiceUrl:{voiceUrlBase}"); } catch (Exception ex) { Debug.LogWarning($"[WefLab] Error parsing alert settings: {ex.Message}; falling back to alertDelay"); } } } /// /// Recursively find a setting value by key within the nested settings object (radio/checkbox/text/...). /// private static string FindSetting(JObject node, string key) { var direct = node[key]; if (direct != null && direct.Type != JTokenType.Object && direct.Type != JTokenType.Array) return direct.ToString(); foreach (var prop in node.Properties()) { if (prop.Value is JObject child) { string found = FindSetting(child, key); if (found != null) return found; } } return null; } /// /// Build one PlatformConnection per linked platform from loginData.config.url. /// Mirrors the weflab web client which opens socket.iop[platform] for each linked platform. /// private void BuildConnections(JObject loginData) { connections.Clear(); var urlConfig = loginData["config"]?["url"] as JObject; var platforms = loginData["platform"] as JObject; if (urlConfig == null || platforms == null) { Debug.LogError("[WefLab] config.url or platform missing in loginData - cannot build connections"); return; } // One donation socket per linked platform (afreeca/naver/youtube/...). // When chat is enabled, also open a second connection per platform on the chat page, // since weflab subscribes alert and chat on separate pages. foreach (var prop in platforms.Properties()) { string platform = prop.Name; string host = urlConfig["socket_" + platform]?.ToString(); string platformId = (prop.Value as JObject)?["id"]?.ToString() ?? ""; if (string.IsNullOrEmpty(host)) { Debug.LogWarning($"[WefLab] No socket server for platform '{platform}' (socket_{platform} not in config.url) - skipping"); continue; } JObject platformData = prop.Value as JObject; connections.Add(new PlatformConnection { platform = platform, platformId = platformId, platformData = platformData, page = "alert", wsUrl = BuildSocketUrl(host, "alert") }); if (enableChat) { connections.Add(new PlatformConnection { platform = platform, platformId = platformId, platformData = platformData, page = "chat", wsUrl = BuildSocketUrl(host, "chat") }); } } // Optionally include the main control socket (ssmain) if (connectMainSocket) { string mainHost = urlConfig["socket"]?.ToString(); if (!string.IsNullOrEmpty(mainHost)) { connections.Add(new PlatformConnection { platform = "main", page = "alert", wsUrl = BuildSocketUrl(mainHost, "alert") }); } } Debug.Log($"[WefLab] Prepared {connections.Count} socket connection(s): {string.Join(", ", connections.ConvertAll(c => c.Label))}"); } /// /// Convert a config host (https://ssafreeca.weflab.com) into a full Socket.IO websocket URL /// subscribed to the given page ("alert" or "chat"). /// private string BuildSocketUrl(string httpHost, string page) { string baseUrl = httpHost .Replace("https://", "wss://") .Replace("http://", "ws://") .TrimEnd('/'); return $"{baseUrl}/socket.io/?idx={userIdx}&type=page&page={page}&EIO=4&transport=websocket"; } /// /// Open every prepared platform connection. /// public void ConnectAll() { foreach (var conn in connections) { ConnectOne(conn); } } /// /// Backwards-compatible alias - connects to all platform sockets. /// public void Connect() { ConnectAll(); } /// /// Open a single platform connection and wire its event handlers. /// private void ConnectOne(PlatformConnection conn) { if (conn.ws != null && conn.ws.IsAlive) { Debug.LogWarning($"[WefLab] ({conn.Label}) Already connected"); return; } VLog($"[WefLab] ({conn.Label}) Connecting to: {conn.wsUrl}"); conn.ws = new WebSocket(conn.wsUrl); // Capture conn in the handlers so each message knows which socket it came from conn.ws.OnOpen += (sender, e) => EnqueueMainThreadAction(() => VLog($"[WefLab] ({conn.Label}) WebSocket connection opened")); conn.ws.OnMessage += (sender, e) => { string data = e.Data; EnqueueMainThreadAction(() => ProcessMessage(conn, data)); }; conn.ws.OnError += (sender, e) => EnqueueMainThreadAction(() => { Debug.LogError($"[WefLab] ({conn.Label}) WebSocket error: {e.Message}"); if (e.Exception != null) Debug.LogError($"[WefLab] ({conn.Label}) Exception: {e.Exception}"); }); conn.ws.OnClose += (sender, e) => EnqueueMainThreadAction(() => { VLog($"[WefLab] ({conn.Label}) WebSocket closed. Code: {e.Code}, Reason: {e.Reason}"); conn.connected = false; conn.engineSid = ""; conn.socketSid = ""; UpdateAggregateState(); }); conn.ws.ConnectAsync(); } /// /// Disconnect every platform connection. /// public void Disconnect() { if (platformKeepAliveCoroutine != null) { StopCoroutine(platformKeepAliveCoroutine); platformKeepAliveCoroutine = null; } foreach (var conn in connections) { if (conn.ws != null) { conn.ws.Close(); conn.ws = null; } conn.connected = false; conn.engineSid = ""; conn.socketSid = ""; conn.platformStarted = false; } UpdateAggregateState(); } /// /// Refresh the aggregate inspector fields (isConnected / currentSid) from all connections. /// private void UpdateAggregateState() { bool any = false; string firstSid = ""; foreach (var c in connections) { if (c.connected) any = true; if (string.IsNullOrEmpty(firstSid) && !string.IsNullOrEmpty(c.engineSid)) firstSid = c.engineSid; } isConnected = any; currentSid = firstSid; } #region Message Processing /// /// Process incoming WebSocket message for a given platform connection /// private void ProcessMessage(PlatformConnection conn, string data) { if (string.IsNullOrEmpty(data)) return; // Get message type (first character or first two characters) string messageType = data.Length >= 2 && char.IsDigit(data[1]) ? data.Substring(0, 2) : data.Substring(0, 1); string payload = data.Substring(messageType.Length); switch (messageType) { case "0": // Engine.IO OPEN HandleEngineOpen(conn, payload); break; case "2": // Engine.IO PING HandlePing(conn); break; case "3": // Engine.IO PONG VLog($"[WefLab] ({conn.Label}) Received PONG"); break; case "40": // Socket.IO CONNECT HandleSocketConnect(conn, payload); break; case "42": // Socket.IO EVENT HandleSocketEvent(conn, payload); break; default: VLog($"[WefLab] ({conn.Label}) Unknown message type: {messageType} | Data: {data}"); break; } } /// /// Handle Engine.IO OPEN message (handshake) /// private void HandleEngineOpen(PlatformConnection conn, string payload) { try { var openData = JsonConvert.DeserializeObject(payload); conn.engineSid = openData["sid"]?.ToString() ?? ""; if (openData["pingInterval"] != null) { pingInterval = openData["pingInterval"].Value() / 1000f; // Convert to seconds } UpdateAggregateState(); VLog($"[WefLab] ({conn.Label}) Engine.IO OPEN - SID: {conn.engineSid}, PingInterval: {pingInterval}s"); VLog($"[WefLab] ({conn.Label}) Full handshake data: {payload}"); // Send Socket.IO CONNECT SendSocketConnect(conn); } catch (Exception ex) { Debug.LogError($"[WefLab] ({conn.Label}) Error parsing OPEN message: {ex.Message}"); } } /// /// Handle Engine.IO PING /// private void HandlePing(PlatformConnection conn) { VLog($"[WefLab] ({conn.Label}) Received PING, sending PONG"); SendMessage(conn, "3"); // Send PONG } /// /// Handle Socket.IO CONNECT response /// private void HandleSocketConnect(PlatformConnection conn, string payload) { try { if (!string.IsNullOrEmpty(payload)) { var connectData = JsonConvert.DeserializeObject(payload); conn.socketSid = connectData["sid"]?.ToString() ?? ""; VLog($"[WefLab] ({conn.Label}) Socket.IO CONNECT - SID: {conn.socketSid}"); VLog($"[WefLab] ({conn.Label}) Full connect data: {payload}"); } conn.connected = true; UpdateAggregateState(); // 1) Register the subscription SendJoinMessage(conn); // 2) Platform sockets must also start the platform feed; this is what actually // triggers donation/chat delivery (join_platform alone is not enough). if (conn.IsPlatform) { SendPlatformMessage(conn); // 3) Keep the feed alive with periodic re-sends (weflab uses config.time.page_platform = 60s) if (platformKeepAliveCoroutine == null) platformKeepAliveCoroutine = StartCoroutine(PlatformKeepAlive()); } } catch (Exception ex) { Debug.LogError($"[WefLab] ({conn.Label}) Error parsing Socket.IO CONNECT: {ex.Message}"); } } /// /// Send the "platform" feed message that starts/maintains donation delivery on a platform socket. /// Mirrors weflab: socket.send(iop, { type:"platform", start, platform:, use:[] }). /// private void SendPlatformMessage(PlatformConnection conn) { bool start = !conn.platformStarted; conn.platformStarted = true; var msg = new { type = "platform", start = start, platform = conn.platformData, use = Array.Empty(), // fields socket.send() auto-appends page = pageType, idx = userIdx, pageid = conn.page, preset = "0" }; string fullMessage = "42" + JsonConvert.SerializeObject(new object[] { "msg", msg }); VLog($"[WefLab] ({conn.Label}) Sending PLATFORM feed message (start={start})"); SendMessage(conn, fullMessage); } /// /// Periodically re-send the platform feed message to keep the donation feed alive (every 60s). /// private IEnumerator PlatformKeepAlive() { var wait = new WaitForSeconds(60f); while (true) { yield return wait; foreach (var conn in connections) { if (conn.IsPlatform && conn.connected && conn.ws != null && conn.ws.IsAlive) SendPlatformMessage(conn); } } } // Subtypes that are NOT monetary donations (subscribe/membership/follow/emoticon). // value here is a month count or nothing, so they must not trigger amount-based events. private static readonly HashSet NonMonetarySubtypes = new HashSet(StringComparer.OrdinalIgnoreCase) { "subscribe", "vip", "follow", "follow_item", "follow_item_effect", "up", "emoticon", "chat" }; /// /// Handle Socket.IO EVENT message. The weflab envelope is ["msg", { type, data, ... }] /// where `type` is "alert" (donation), "chat", "info", etc. and `data` is the flat payload. /// private void HandleSocketEvent(PlatformConnection conn, string payload) { try { var eventArray = JsonConvert.DeserializeObject(payload); if (eventArray == null || eventArray.Count < 1) return; string eventName = eventArray[0].ToString(); // The server also emits bare events like ["pong"] - ignore anything that isn't a "msg" payload if (eventName != "msg") { VLog($"[WefLab] ({conn.Label}) Socket event '{eventName}' (ignored)"); return; } if (eventArray.Count < 2) return; var envelope = eventArray[1] as JObject; VLog($"[WefLab] ===== EVENT ({conn.Label}) name={eventName} type={envelope?["type"]} ====="); // Verbose: dump the full raw payload so real (non-test) platform structures can be verified if (verboseLog && envelope != null) Debug.Log($"[WefLab] RAW ({conn.Label}):\n{envelope.ToString(Formatting.Indented)}"); HandleMessageEnvelope(conn.platform, envelope); } catch (Exception ex) { Debug.LogError($"[WefLab] ({conn.Label}) Error parsing Socket.IO EVENT: {ex.Message}"); Debug.LogError($"[WefLab] Payload: {payload}"); } } /// /// Route a "msg" envelope by its outer type. fallbackPlatform is the socket's platform, /// used when the flat payload itself does not carry a platform field. /// private void HandleMessageEnvelope(string fallbackPlatform, JObject envelope) { if (envelope == null) return; string envType = envelope["type"]?.ToString() ?? ""; // The real donation/chat payload is the flat `data` object. // (Older/legacy flat messages fall back to the envelope itself.) JObject data = envelope["data"] as JObject ?? envelope; switch (envType) { case "alert": // donation (all platforms/subtypes) case "test_donation": // legacy flat test donation HandleAlert(fallbackPlatform, data); break; case "chat": // normal chat message case "warn": // connection warning (password/subonly/donationoff ...) HandleChat(fallbackPlatform, envType, data); break; default: VLog($"[WefLab] ({fallbackPlatform}) Ignored envelope type: '{envType}'"); break; } } /// /// Convert a platform's raw donation value to KRW. /// Unit semantics confirmed by live testing: /// - afreeca/soop/soopg/twitch: value is an item count (balloon/bit), 1 unit = 100 KRW (x100) /// - naver(chzzk)/youtube/cime/extdona: value is already the KRW amount (x1, as-is) /// private static int ConvertToKrw(string platform, JObject data, out int rawValue) { rawValue = 0; if (data["value"] != null) int.TryParse(data["value"].ToString(), out rawValue); switch ((platform ?? "").ToLowerInvariant()) { case "afreeca": case "soop": case "soopg": case "twitch": return rawValue * 100; // count (balloon / bit) -> KRW default: // naver(chzzk), youtube, cime, extdona: value is already KRW return rawValue; } } /// /// Handle an "alert" payload (a monetary donation on any platform). /// private void HandleAlert(string fallbackPlatform, JObject data) { if (data == null) return; string platform = data["platform"]?.ToString() ?? fallbackPlatform; // Real alerts use "subtype"; dashboard test donations use "type" instead. string subtype = data["subtype"]?.ToString(); if (string.IsNullOrEmpty(subtype)) subtype = data["type"]?.ToString() ?? ""; int amount = ConvertToKrw(platform, data, out int rawValue); // Skip non-monetary alerts (subscribe / membership / follow / emoticon, value = 0, ...) if (NonMonetarySubtypes.Contains(subtype) || amount <= 0) { VLog($"[WefLab] ({platform}) Non-monetary alert ignored (subtype:'{subtype}', value:{rawValue}, krw:{amount})"); return; } string message = data["msg"]?.ToString() ?? data["message"]?.ToString() ?? ""; // Real alerts use "name"; test donations use "uname". string donorName = data["name"]?.ToString() ?? data["uname"]?.ToString() ?? data["id"]?.ToString() ?? "Anonymous"; string donorId = data["id"]?.ToString() ?? ""; long timestamp = data["time"]?.Value() ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); DonationData donation = new() { platform = platform, donationType = subtype, subtype = subtype, amount = amount, rawValue = rawValue, message = message, donorName = donorName, donorId = donorId, timestamp = timestamp }; Debug.Log($"[WefLab] DONATION ({platform}/{subtype}): {donorName} | raw {rawValue} -> {amount} KRW | {message}"); EnqueueDonation(donation); } /// /// Handle a "chat" payload. Structured and wired through onChatReceived for later use; /// no-op unless enableChat is turned on. /// private void HandleChat(string fallbackPlatform, string envType, JObject data) { if (!enableChat || data == null) return; string platform = data["platform"]?.ToString() ?? fallbackPlatform; string type = data["type"]?.ToString() ?? envType; string subtype = data["subtype"]?.ToString() ?? ""; bool isWarn = envType == "warn" || type == "warn"; var grade = data["grade"] as JObject; ChatData chat = new() { platform = platform, type = type, subtype = subtype, nickname = data["name"]?.ToString() ?? data["id"]?.ToString() ?? "", userId = data["id"]?.ToString() ?? "", message = data["msg"]?.ToString() ?? "", nickColor = data["color"]?.ToString() ?? "", isStreamer = HasGrade(grade, "streamer"), isManager = HasGrade(grade, "manager"), isFan = HasGrade(grade, "fan"), isSubscriber = HasGrade(grade, "subscribe"), isVip = HasGrade(grade, "vip"), badges = ParseBadges(grade), emoticons = ParseEmoticons(data["emoticon"]), isWarn = isWarn, warnType = isWarn ? subtype : "", timestamp = data["time"]?.Value() ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }; if (isWarn) VLog($"[WefLab] CHAT WARN ({platform}): {chat.warnType}"); else VLog($"[WefLab] CHAT ({platform}): {chat.nickname}: {chat.message}" + (chat.emoticons.Count > 0 ? $" [+{chat.emoticons.Count} emoticon]" : "")); onChatReceived?.Invoke(chat); } /// /// True if a grade flag is set. weflab marks grades as booleans (streamer/manager/fan/subscribe) /// or as a value such as an image URL (vip) - presence of the key counts as set. /// private static bool HasGrade(JObject grade, string key) { if (grade == null) return false; var token = grade[key]; if (token == null || token.Type == JTokenType.Null) return false; if (token.Type == JTokenType.Boolean) return token.Value(); // Non-null, non-bool (e.g. a url string) => present/true return true; } /// /// Extract platform badge ids from data.grade.badge (an array of strings). /// private static string[] ParseBadges(JObject grade) { if (grade?["badge"] is not JArray badgeArray || badgeArray.Count == 0) return Array.Empty(); var list = new List(); foreach (var b in badgeArray) { string s = b?.ToString(); if (!string.IsNullOrEmpty(s)) list.Add(s); } return list.ToArray(); } /// /// Parse data.emoticon into a flat list. weflab uses a map { name: [count, imageUrl, type], ... }; /// an array form [ [name?, imageUrl], ... ] is also handled defensively. /// private static List ParseEmoticons(JToken token) { var result = new List(); if (token == null) return result; if (token is JObject map) { // { "name": [count, img, type], ... } foreach (var prop in map.Properties()) { string img = null; if (prop.Value is JArray arr && arr.Count >= 2) img = arr[1]?.ToString(); else if (prop.Value is JObject obj) img = obj["img"]?.ToString() ?? obj["imageUrl"]?.ToString(); if (!string.IsNullOrEmpty(img)) result.Add(new ChatEmoticon { name = prop.Name, imageUrl = img }); } } else if (token is JArray list) { foreach (var item in list) { if (item is JArray arr && arr.Count >= 2) result.Add(new ChatEmoticon { name = arr[0]?.ToString(), imageUrl = arr[1]?.ToString() }); else if (item is JObject obj) { string img = obj["img"]?.ToString() ?? obj["imageUrl"]?.ToString(); if (!string.IsNullOrEmpty(img)) result.Add(new ChatEmoticon { name = obj["name"]?.ToString(), imageUrl = img }); } } } return result; } /// /// Enqueue donation for sequential processing /// private void EnqueueDonation(DonationData donation) { // Drop duplicates that arrive on more than one socket within the window if (IsDuplicateDonation(donation)) { VLog($"[WefLab] Duplicate donation ignored: {donation.donorName} {donation.amount} ({donation.platform})"); return; } // Immediate notification (before queue/lead-in) for instant side effects (e.g. running totals) onDonationReceived?.Invoke(donation); if (enableQueue) { // Check queue size limit if (maxQueueSize > 0 && donationQueue.Count >= maxQueueSize) { Debug.LogWarning($"[WefLab] Queue full ({maxQueueSize}), dropping oldest donation"); donationQueue.Dequeue(); } donationQueue.Enqueue(donation); queueCount = donationQueue.Count; VLog($"[WefLab] Donation queued. Queue size: {queueCount}"); // Start queue processor if not running if (!isProcessingQueue) { queueProcessorCoroutine = StartCoroutine(ProcessDonationQueue()); } } else { // Direct trigger without queue (original behavior) TriggerDonationEvents(donation); } } /// /// Returns true if a donation with an identical signature was already seen within duplicateWindow. /// Guards against the same alert being delivered on more than one socket. /// private bool IsDuplicateDonation(DonationData donation) { if (duplicateWindow <= 0f) return false; float now = Time.time; string signature = $"{donation.platform}|{donation.donationType}|{donation.amount}|{donation.donorName}|{donation.timestamp}"; // Purge stale entries so the dictionary does not grow unbounded if (recentDonations.Count > 0) { var stale = new List(); foreach (var kvp in recentDonations) { if (now - kvp.Value > duplicateWindow) stale.Add(kvp.Key); } foreach (var key in stale) recentDonations.Remove(key); } if (recentDonations.TryGetValue(signature, out float lastSeen) && now - lastSeen < duplicateWindow) return true; recentDonations[signature] = now; return false; } /// /// Process donation queue sequentially, mirroring the weflab overlay's one-at-a-time pacing. /// Precise mode (syncWithAlertAudio): at the front of the queue, fetch the same TTS audio weflab /// synthesizes, fire the event when the fetch completes (= overlay reveal), then hold for the /// measured audio length. Fallback mode: alertLeadTime -> trigger -> alertDelay. /// private IEnumerator ProcessDonationQueue() { isProcessingQueue = true; while (donationQueue.Count > 0) { var donation = donationQueue.Dequeue(); queueCount = donationQueue.Count; bool handled = false; if (syncWithAlertAudio && settingsLoaded && !string.IsNullOrEmpty(voiceUrlBase)) { // Fetch the TTS audio (this also consumes the same synthesis latency the overlay sees, // so completion time ~= the overlay's popupshow/reveal moment). var audio = new AudioFetchResult(); yield return StartCoroutine(FetchTtsDuration(ComposeTtsText(donation), audio)); if (audio.success) { // Small constant trim for TTS cache skew / decode if (audioStartOffset > 0f) yield return new WaitForSeconds(audioStartOffset); // Reveal moment TriggerDonationEvents(donation); float hold = Mathf.Min(audio.length, settingPopupTimeCap); Debug.Log($"[WefLab] Alert revealed (auto offset {audio.fetchSeconds:F2}s +{audioStartOffset:F2}). Audio {audio.length:F2}s, holding {hold:F2}s. Remaining: {donationQueue.Count}"); // Hold for the audio length; gap before next alert appears (overlay's inter-alert pause) yield return new WaitForSeconds(hold + interAlertGap); handled = true; } else { Debug.LogWarning($"[WefLab] TTS fetch failed ({audio.error}); using fallback timing for this alert"); } } if (!handled) { // Fallback: fixed lead-in + display time if (alertLeadTime > 0f) yield return new WaitForSeconds(alertLeadTime); TriggerDonationEvents(donation); Debug.Log($"[WefLab] Alert triggered (fallback, lead {alertLeadTime:F1}s, hold {alertDelay:F1}s). Remaining: {donationQueue.Count}"); yield return new WaitForSeconds(alertDelay); } } isProcessingQueue = false; queueProcessorCoroutine = null; } /// Result holder for the async TTS fetch. private class AudioFetchResult { public bool success; public float length; // measured audio clip length (seconds) public float fetchSeconds; // wall-clock spent fetching (~= overlay reveal offset) public string error; } /// /// Replicate weflab's TTS text: the alert template with tokens replaced, plus the donor message. /// private string ComposeTtsText(DonationData donation) { string label = LabelFor(donation.platform, donation.subtype); string unit = UnitFor(donation.platform, donation.subtype); string count = donation.rawValue > 0 ? donation.rawValue.ToString() : ""; string text = ttsTemplate ?? ""; text = Regex.Replace(text, @"\{닉네임\}|\{이름\}|\{nickname\}", donation.donorName ?? ""); text = Regex.Replace(text, @"\{종류\}|\{type\}", label); text = Regex.Replace(text, @"\{개수\}|\{개월\}|\{number\}", count); text = Regex.Replace(text, @"\{개\}|\{unit\}", unit); // Strip any remaining unreplaced tokens text = Regex.Replace(text, @"\{[^}]*\}", ""); text = Regex.Replace(text, @"\s+", " ").Trim(); // weflab reads the donor message after the alert template (signsound voice) if (!string.IsNullOrEmpty(donation.message)) text = string.IsNullOrEmpty(text) ? donation.message : text + " " + donation.message; return text; } private static string LabelFor(string platform, string subtype) { switch ((subtype ?? "").ToUpperInvariant()) { case "SENDBALLOON": case "VIDEOBALLOON": case "ADBALLOON": return "별풍선"; case "GEM": return "젬"; case "CHZZK": return "치즈"; case "SUPERCHAT": return "슈퍼챗"; case "STICKER": return "스티커"; case "BITS": return "비트"; case "BEAM": return "빔"; } // Platform fallback switch ((platform ?? "").ToLowerInvariant()) { case "afreeca": case "soop": case "soopg": return "별풍선"; case "naver": case "chzzk": return "치즈"; case "youtube": return "슈퍼챗"; case "twitch": return "비트"; default: return "후원"; } } private static string UnitFor(string platform, string subtype) { switch ((platform ?? "").ToLowerInvariant()) { case "naver": case "chzzk": case "youtube": return "원"; default: return "개"; // balloons / bits / gem } } /// /// Fetch weflab's TTS for the given text and measure the resulting audio clip length. /// The wall-clock spent here approximates the overlay's synthesis-to-reveal latency. /// private IEnumerator FetchTtsDuration(string text, AudioFetchResult result) { float t0 = Time.realtimeSinceStartup; if (string.IsNullOrEmpty(text)) { result.error = "empty text"; yield break; } // 1) Resolve the MP3 URL: GET voiceurl?text=...&speed=...&lang=... string reqUrl = $"{voiceUrlBase}?text={UnityWebRequest.EscapeURL(text)}&speed={ttsSpeed}&lang={ttsLang}"; string mp3Url; using (UnityWebRequest urlReq = UnityWebRequest.Get(reqUrl)) { urlReq.SetRequestHeader("Referer", pageUrl); yield return urlReq.SendWebRequest(); if (urlReq.result != UnityWebRequest.Result.Success) { result.error = $"voiceurl: {urlReq.error}"; yield break; } mp3Url = urlReq.downloadHandler.text?.Trim().Trim('"'); } if (string.IsNullOrEmpty(mp3Url)) { result.error = "empty mp3 url"; yield break; } if (!mp3Url.StartsWith("http")) mp3Url = baseDomain + (mp3Url.StartsWith("/") ? "" : "/") + mp3Url; // 2) Download the MP3 as an AudioClip and read its length using (UnityWebRequest clipReq = UnityWebRequestMultimedia.GetAudioClip(mp3Url, AudioType.MPEG)) { clipReq.SetRequestHeader("Referer", pageUrl); yield return clipReq.SendWebRequest(); if (clipReq.result != UnityWebRequest.Result.Success) { result.error = $"mp3: {clipReq.error}"; yield break; } AudioClip clip = DownloadHandlerAudioClip.GetContent(clipReq); if (clip == null || clip.length <= 0f) { result.error = "invalid clip"; yield break; } result.length = clip.length; result.fetchSeconds = Time.realtimeSinceStartup - t0; result.success = true; } } /// /// Trigger donation events based on amount range /// private void TriggerDonationEvents(DonationData donation) { if (donationTriggers == null || donationTriggers.Length == 0) { Debug.LogWarning("[WefLab] No donation triggers configured"); return; } bool triggeredAny = false; // Check each trigger foreach (var trigger in donationTriggers) { if (trigger == null) continue; // Check if donation amount is in range if (trigger.IsInRange(donation.amount)) { int repeatCount = trigger.GetRepeatCount(donation.amount); Debug.Log($"[WefLab] Triggering: {trigger.triggerName} (Amount: {donation.amount}, Range: {trigger.minAmount}-{(trigger.maxAmount >= 0 ? trigger.maxAmount.ToString() : "unlimited")}, Repeat: {repeatCount}x)"); // Start coroutine to handle repeated invocation StartCoroutine(InvokeRepeatedEvent(trigger, donation, repeatCount)); triggeredAny = true; } } if (!triggeredAny) { Debug.LogWarning($"[WefLab] No triggers matched for donation amount: {donation.amount}"); } } /// /// Coroutine to invoke event multiple times with delay /// private IEnumerator InvokeRepeatedEvent(DonationEventTrigger trigger, DonationData donation, int repeatCount) { for (int i = 0; i < repeatCount; i++) { // Invoke the event trigger.onDonation?.Invoke(donation); // Wait for delay before next repetition (skip on last iteration) if (i < repeatCount - 1 && trigger.repeatDelay > 0) { yield return new WaitForSeconds(trigger.repeatDelay); } } } #endregion #region Send Messages /// /// Send Socket.IO CONNECT message (40) /// private void SendSocketConnect(PlatformConnection conn) { VLog($"[WefLab] ({conn.Label}) Sending Socket.IO CONNECT (40)"); SendMessage(conn, "40"); } /// /// Send the subscription message for this connection. /// Platform sockets (ssafreeca/ssnaver/...) use "join_platform" with the platform + account id; /// the main control socket (ssmain) uses the generic "join". Mirrors weflab's socket.connect / socket.join. /// private void SendJoinMessage(PlatformConnection conn) { object joinData; if (conn.platform == "main") { joinData = new { type = "join", page = pageType, // "page" idx = userIdx, pageid = conn.page, // "alert" or "chat" preset = "0" }; } else { // Platform subscription - this is what actually delivers donation/chat events joinData = new { type = "join_platform", platform = conn.platform, id = conn.platformId, page = pageType, // "page" idx = userIdx, pageid = conn.page, // "alert" or "chat" preset = "0" }; } var message = new object[] { "msg", joinData }; string json = JsonConvert.SerializeObject(message); string fullMessage = "42" + json; VLog($"[WefLab] ({conn.Label}) Sending JOIN message: {fullMessage}"); SendMessage(conn, fullMessage); } /// /// Send raw message through a specific platform connection /// private void SendMessage(PlatformConnection conn, string message) { if (conn.ws != null && conn.ws.IsAlive) { conn.ws.Send(message); } else { Debug.LogWarning($"[WefLab] ({conn.Label}) Cannot send message - WebSocket not connected"); } } #endregion #region Thread Safety /// /// Enqueue action to be executed on main thread /// private void EnqueueMainThreadAction(Action action) { lock (actionLock) { mainThreadActions.Enqueue(action); } } #endregion #region Public Methods /// /// Reconnect to WebSocket /// public void Reconnect() { Disconnect(); Connect(); } /// /// Check if connected /// public bool IsConnected() { foreach (var conn in connections) { if (conn.connected && conn.ws != null && conn.ws.IsAlive) return true; } return false; } /// /// Get current queue count /// public int GetQueueCount() { return donationQueue.Count; } /// /// Check if queue is being processed /// public bool IsQueueProcessing() { return isProcessingQueue; } /// /// Clear all pending donations in queue /// public void ClearQueue() { donationQueue.Clear(); queueCount = 0; Debug.Log("[WefLab] Queue cleared"); } /// /// Skip current alert and move to next in queue /// public void SkipCurrentAlert() { if (queueProcessorCoroutine != null) { StopCoroutine(queueProcessorCoroutine); queueProcessorCoroutine = null; } if (donationQueue.Count > 0) { Debug.Log("[WefLab] Skipping to next alert"); queueProcessorCoroutine = StartCoroutine(ProcessDonationQueue()); } else { isProcessingQueue = false; Debug.Log("[WefLab] No more alerts in queue"); } } /// /// Pause queue processing /// public void PauseQueue() { if (queueProcessorCoroutine != null) { StopCoroutine(queueProcessorCoroutine); queueProcessorCoroutine = null; isProcessingQueue = false; Debug.Log("[WefLab] Queue paused"); } } /// /// Resume queue processing /// public void ResumeQueue() { if (!isProcessingQueue && donationQueue.Count > 0) { queueProcessorCoroutine = StartCoroutine(ProcessDonationQueue()); Debug.Log("[WefLab] Queue resumed"); } } #endregion } }