diff --git a/Assets/Scripts/WefLab.meta b/Assets/Scripts/WefLab.meta new file mode 100644 index 00000000..14ad623e --- /dev/null +++ b/Assets/Scripts/WefLab.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8cc56f0c0c16686438285879ef2a7583 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/WefLab/WefLabWebSocketClient.cs b/Assets/Scripts/WefLab/WefLabWebSocketClient.cs new file mode 100644 index 00000000..9f1a30c0 --- /dev/null +++ b/Assets/Scripts/WefLab/WefLabWebSocketClient.cs @@ -0,0 +1,729 @@ +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; + public string donationType; + public int amount; + public string message; + public string donorName; + public long timestamp; + } + + /// + /// Unity event with donation data parameter + /// + [System.Serializable] + public class DonationEvent : 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("Event Settings")] + [Tooltip("Number of times to repeat the event (minimum 1)")] + [Min(1)] + public int repeatCount = 1; + + [Tooltip("Delay between each repeat in seconds (0 for immediate)")] + [Min(0)] + public float repeatDelay = 0f; + + [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; + } + } + + /// + /// 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"; + + [Header("Donation Event Triggers")] + [Tooltip("Donation event triggers based on amount ranges")] + public DonationEventTrigger[] donationTriggers = Array.Empty(); + + // Hidden connection info + [HideInInspector] public bool isConnected = false; + [HideInInspector] public string currentSid = ""; + [HideInInspector] public string extractedUserIdx = ""; + + // WebSocket connection + private WebSocket ws; + private string userIdx = ""; // Will be extracted from page + private string socketSid = ""; + private bool isExtracting = false; + + // Ping/Pong settings + private float pingInterval = 30f; + private float lastPingTime = 0f; + + // Thread-safe action queue for main thread + private Queue mainThreadActions = new Queue(); + private object actionLock = new object(); + + void Start() + { + // Extract user idx from page URL and then connect + StartCoroutine(ExtractUserIdxAndConnect()); + } + + 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}"); + } + 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 extracted the userIdx + if (!string.IsNullOrEmpty(userIdx)) + { + Connect(); + } + else + { + Debug.LogError("[WefLab] Cannot connect - userIdx extraction failed"); + } + } + + /// + /// Connect to WefLab WebSocket server + /// + public void Connect() + { + if (ws != null && ws.IsAlive) + { + Debug.LogWarning("[WefLab] Already connected"); + return; + } + + // Build WebSocket URL + string wsUrl = $"wss://ssmain.weflab.com/socket.io/?idx={userIdx}&type=page&page=alert&EIO=4&transport=websocket"; + + Debug.Log($"[WefLab] Connecting to: {wsUrl}"); + + ws = new WebSocket(wsUrl); + + // Event handlers + ws.OnOpen += OnWebSocketOpen; + ws.OnMessage += OnWebSocketMessage; + ws.OnError += OnWebSocketError; + ws.OnClose += OnWebSocketClose; + + // Connect + ws.ConnectAsync(); + } + + /// + /// Disconnect from WebSocket + /// + public void Disconnect() + { + if (ws != null) + { + ws.Close(); + ws = null; + } + + isConnected = false; + socketSid = ""; + currentSid = ""; + } + + #region WebSocket Event Handlers + + private void OnWebSocketOpen(object sender, EventArgs e) + { + EnqueueMainThreadAction(() => + { + Debug.Log("[WefLab] WebSocket connection opened"); + }); + } + + private void OnWebSocketMessage(object sender, MessageEventArgs e) + { + string data = e.Data; + + EnqueueMainThreadAction(() => + { + ProcessMessage(data); + }); + } + + private void OnWebSocketError(object sender, ErrorEventArgs e) + { + EnqueueMainThreadAction(() => + { + Debug.LogError($"[WefLab] WebSocket error: {e.Message}"); + if (e.Exception != null) + { + Debug.LogError($"[WefLab] Exception: {e.Exception}"); + } + }); + } + + private void OnWebSocketClose(object sender, CloseEventArgs e) + { + EnqueueMainThreadAction(() => + { + Debug.Log($"[WefLab] WebSocket closed. Code: {e.Code}, Reason: {e.Reason}"); + isConnected = false; + socketSid = ""; + currentSid = ""; + }); + } + + #endregion + + #region Message Processing + + /// + /// Process incoming WebSocket message + /// + private void ProcessMessage(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(payload); + break; + + case "2": // Engine.IO PING + HandlePing(); + break; + + case "3": // Engine.IO PONG + Debug.Log("[WefLab] Received PONG"); + break; + + case "40": // Socket.IO CONNECT + HandleSocketConnect(payload); + break; + + case "42": // Socket.IO EVENT + HandleSocketEvent(payload); + break; + + default: + Debug.Log($"[WefLab] Unknown message type: {messageType} | Data: {data}"); + break; + } + } + + /// + /// Handle Engine.IO OPEN message (handshake) + /// + private void HandleEngineOpen(string payload) + { + try + { + var openData = JsonConvert.DeserializeObject(payload); + currentSid = openData["sid"]?.ToString() ?? ""; + + if (openData["pingInterval"] != null) + { + pingInterval = openData["pingInterval"].Value() / 1000f; // Convert to seconds + } + + Debug.Log($"[WefLab] Engine.IO OPEN - SID: {currentSid}, PingInterval: {pingInterval}s"); + Debug.Log($"[WefLab] Full handshake data: {payload}"); + + // Send Socket.IO CONNECT + SendSocketConnect(); + } + catch (Exception ex) + { + Debug.LogError($"[WefLab] Error parsing OPEN message: {ex.Message}"); + } + } + + /// + /// Handle Engine.IO PING + /// + private void HandlePing() + { + Debug.Log("[WefLab] Received PING, sending PONG"); + SendMessage("3"); // Send PONG + } + + /// + /// Handle Socket.IO CONNECT response + /// + private void HandleSocketConnect(string payload) + { + try + { + if (!string.IsNullOrEmpty(payload)) + { + var connectData = JsonConvert.DeserializeObject(payload); + socketSid = connectData["sid"]?.ToString() ?? ""; + Debug.Log($"[WefLab] Socket.IO CONNECT - SID: {socketSid}"); + Debug.Log($"[WefLab] Full connect data: {payload}"); + } + + isConnected = true; + + // Send join message + SendJoinMessage(); + } + catch (Exception ex) + { + Debug.LogError($"[WefLab] Error parsing Socket.IO CONNECT: {ex.Message}"); + } + } + + /// + /// Handle Socket.IO EVENT message (donation data) + /// + private void HandleSocketEvent(string payload) + { + try + { + var eventArray = JsonConvert.DeserializeObject(payload); + + if (eventArray == null || eventArray.Count < 2) + { + Debug.LogWarning($"[WefLab] Invalid event format: {payload}"); + return; + } + + string eventName = eventArray[0].ToString(); + var eventData = eventArray[1] as JObject; + + Debug.Log($"[WefLab] ========== EVENT RECEIVED =========="); + Debug.Log($"[WefLab] Event Name: {eventName}"); + Debug.Log($"[WefLab] Event Data: {eventData?.ToString(Formatting.Indented)}"); + Debug.Log($"[WefLab] ==================================="); + + if (eventName == "msg") + { + HandleMessageEvent(eventData); + } + } + catch (Exception ex) + { + Debug.LogError($"[WefLab] Error parsing Socket.IO EVENT: {ex.Message}"); + Debug.LogError($"[WefLab] Payload: {payload}"); + } + } + + /// + /// Handle "msg" event (donation notifications) + /// + private void HandleMessageEvent(JObject msgData) + { + if (msgData == null) + return; + + string msgType = msgData["type"]?.ToString() ?? ""; + + switch (msgType) + { + case "test_donation": + HandleTestDonation(msgData); + break; + + case "donation": + case "SENDBALLOON": + case "cheese": + case "superchat": + case "bits": + HandleDonation(msgData); + break; + + default: + Debug.Log($"[WefLab] Unhandled message type: {msgType}"); + Debug.Log($"[WefLab] Data: {msgData.ToString(Formatting.Indented)}"); + break; + } + } + + /// + /// Normalize donation amount to unified currency (Chzzk standard: 1 = 1 KRW) + /// SOOP (Afreeca): 1 balloon = 100 KRW → multiply by 100 + /// Chzzk: 1 cheese = 1 KRW → no conversion + /// YouTube: Keep as-is (already in KRW equivalent) + /// Twitch: Keep as-is + /// + private static int NormalizeDonationAmount(string platform, int rawAmount) + { + platform = platform.ToLower(); + + if (platform == "afreeca" || platform == "soop") + { + // SOOP: 1 balloon = 100 KRW + return rawAmount * 100; + } + + // Chzzk, YouTube, Twitch: no conversion needed + return rawAmount; + } + + /// + /// Handle test donation event + /// + private void HandleTestDonation(JObject msgData) + { + if (msgData["data"] is not JObject donationDataJson) + return; + + string platform = donationDataJson["platform"]?.ToString() ?? "unknown"; + string type = donationDataJson["type"]?.ToString() ?? "unknown"; + int rawAmount = 0; + + // Parse amount + if (int.TryParse(donationDataJson["value"]?.ToString(), out int parsedAmount)) + { + rawAmount = parsedAmount; + } + + // Normalize amount based on platform + int amount = NormalizeDonationAmount(platform, rawAmount); + + string message = donationDataJson["msg"]?.ToString() ?? ""; + long timestamp = donationDataJson["time"]?.Value() ?? 0; + + Debug.Log($"[WefLab] *** TEST DONATION ***"); + Debug.Log($"[WefLab] Platform: {platform}"); + Debug.Log($"[WefLab] Type: {type}"); + Debug.Log($"[WefLab] Raw Amount: {rawAmount} → Normalized: {amount} KRW"); + Debug.Log($"[WefLab] Message: {message}"); + Debug.Log($"[WefLab] Timestamp: {timestamp}"); + + // Create donation data and trigger events + DonationData donation = new() + { + platform = platform, + donationType = type, + amount = amount, + message = message, + donorName = "Test Donor", + timestamp = timestamp + }; + + TriggerDonationEvents(donation); + } + + /// + /// Handle real donation event + /// + private void HandleDonation(JObject msgData) + { + Debug.Log($"[WefLab] *** REAL DONATION ***"); + Debug.Log($"[WefLab] Full donation data: {msgData.ToString(Formatting.Indented)}"); + + if (msgData["data"] is not JObject donationDataJson) + return; + + string platform = msgData["platform"]?.ToString() ?? "unknown"; + string type = msgData["type"]?.ToString() ?? "unknown"; + int rawAmount = 0; + + // Parse amount from different possible fields + if (donationDataJson["value"] != null && int.TryParse(donationDataJson["value"].ToString(), out int parsedValue)) + { + rawAmount = parsedValue; + } + else if (donationDataJson["amount"] != null && int.TryParse(donationDataJson["amount"].ToString(), out int parsedAmount)) + { + rawAmount = parsedAmount; + } + + // Normalize amount based on platform + int amount = NormalizeDonationAmount(platform, rawAmount); + + string message = donationDataJson["msg"]?.ToString() ?? donationDataJson["message"]?.ToString() ?? ""; + string donorName = donationDataJson["nickname"]?.ToString() ?? donationDataJson["name"]?.ToString() ?? "Anonymous"; + long timestamp = donationDataJson["time"]?.Value() ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + // Create donation data + DonationData donation = new() + { + platform = platform, + donationType = type, + amount = amount, + message = message, + donorName = donorName, + timestamp = timestamp + }; + + Debug.Log($"[WefLab] Donation: {donorName} - {rawAmount} → {amount} KRW ({platform})"); + + // Trigger events + TriggerDonationEvents(donation); + } + + /// + /// 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)) + { + Debug.Log($"[WefLab] Triggering: {trigger.triggerName} (Amount: {donation.amount}, Range: {trigger.minAmount}-{(trigger.maxAmount >= 0 ? trigger.maxAmount.ToString() : "unlimited")}, Repeat: {trigger.repeatCount}x)"); + + // Start coroutine to handle repeated invocation + StartCoroutine(InvokeRepeatedEvent(trigger, donation)); + 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) + { + for (int i = 0; i < trigger.repeatCount; i++) + { + // Invoke the event + trigger.onDonation?.Invoke(donation); + + // Wait for delay before next repetition (skip on last iteration) + if (i < trigger.repeatCount - 1 && trigger.repeatDelay > 0) + { + yield return new WaitForSeconds(trigger.repeatDelay); + } + } + } + + #endregion + + #region Send Messages + + /// + /// Send Socket.IO CONNECT message (40) + /// + private void SendSocketConnect() + { + Debug.Log("[WefLab] Sending Socket.IO CONNECT (40)"); + SendMessage("40"); + } + + /// + /// Send join message to subscribe to donation events + /// + private void SendJoinMessage() + { + var joinData = new + { + type = "join", + page = "page", + idx = userIdx, + pageid = "alert", + preset = "0" + }; + + var message = new object[] { "msg", joinData }; + string json = JsonConvert.SerializeObject(message); + string fullMessage = "42" + json; + + Debug.Log($"[WefLab] Sending JOIN message: {fullMessage}"); + SendMessage(fullMessage); + } + + /// + /// Send raw message through WebSocket + /// + private void SendMessage(string message) + { + if (ws != null && ws.IsAlive) + { + ws.Send(message); + } + else + { + Debug.LogWarning("[WefLab] 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() + { + return isConnected && ws != null && ws.IsAlive; + } + + #endregion + } +} diff --git a/Assets/Scripts/WefLab/WefLabWebSocketClient.cs.meta b/Assets/Scripts/WefLab/WefLabWebSocketClient.cs.meta new file mode 100644 index 00000000..0081856b --- /dev/null +++ b/Assets/Scripts/WefLab/WefLabWebSocketClient.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5b7cb7cbeddf11148824325615980e1a \ No newline at end of file