Streamingle_URP/Assets/Scripts/WefLab/WefLabWebSocketClient.cs

921 lines
29 KiB
C#

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
{
/// <summary>
/// Donation data structure
/// </summary>
[System.Serializable]
public class DonationData
{
public string platform;
public string donationType;
public int amount;
public string message;
public string donorName;
public long timestamp;
}
/// <summary>
/// Unity event with donation data parameter
/// </summary>
[System.Serializable]
public class DonationEvent : UnityEvent<DonationData> { }
/// <summary>
/// Donation event trigger based on amount range
/// </summary>
[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;
/// <summary>
/// Check if donation amount is within range
/// </summary>
public bool IsInRange(int amount)
{
if (amount < minAmount)
return false;
if (maxAmount >= 0 && amount > maxAmount)
return false;
return true;
}
/// <summary>
/// Calculate repeat count based on donation amount
/// </summary>
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;
}
}
/// <summary>
/// WefLab WebSocket client for receiving donation events
/// Implements Engine.IO v4 and Socket.IO protocol
/// </summary>
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<DonationEventTrigger>();
[Header("Queue Settings")]
[Tooltip("Enable sequential processing of donations")]
public bool enableQueue = true;
[Tooltip("Delay between each donation alert (seconds)")]
[Min(0.1f)]
public float alertDelay = 3f;
[Tooltip("Maximum queue size (0 = unlimited)")]
[Min(0)]
public int maxQueueSize = 50;
// 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<DonationData> donationQueue = new Queue<DonationData>();
private Coroutine queueProcessorCoroutine = null;
// 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<Action> mainThreadActions = new Queue<Action>();
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();
}
/// <summary>
/// Extract user index from page URL and connect
/// </summary>
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<JObject>(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");
}
}
/// <summary>
/// Connect to WefLab WebSocket server
/// </summary>
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();
}
/// <summary>
/// Disconnect from WebSocket
/// </summary>
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
/// <summary>
/// Process incoming WebSocket message
/// </summary>
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;
}
}
/// <summary>
/// Handle Engine.IO OPEN message (handshake)
/// </summary>
private void HandleEngineOpen(string payload)
{
try
{
var openData = JsonConvert.DeserializeObject<JObject>(payload);
currentSid = openData["sid"]?.ToString() ?? "";
if (openData["pingInterval"] != null)
{
pingInterval = openData["pingInterval"].Value<float>() / 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}");
}
}
/// <summary>
/// Handle Engine.IO PING
/// </summary>
private void HandlePing()
{
Debug.Log("[WefLab] Received PING, sending PONG");
SendMessage("3"); // Send PONG
}
/// <summary>
/// Handle Socket.IO CONNECT response
/// </summary>
private void HandleSocketConnect(string payload)
{
try
{
if (!string.IsNullOrEmpty(payload))
{
var connectData = JsonConvert.DeserializeObject<JObject>(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}");
}
}
/// <summary>
/// Handle Socket.IO EVENT message (donation data)
/// </summary>
private void HandleSocketEvent(string payload)
{
try
{
var eventArray = JsonConvert.DeserializeObject<JArray>(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}");
}
}
/// <summary>
/// Handle "msg" event (donation notifications)
/// </summary>
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;
}
}
/// <summary>
/// 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
/// </summary>
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;
}
/// <summary>
/// Handle test donation event
/// </summary>
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<long>() ?? 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
};
EnqueueDonation(donation);
}
/// <summary>
/// Handle real donation event
/// </summary>
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<long>() ?? 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})");
// Enqueue donation for sequential processing
EnqueueDonation(donation);
}
/// <summary>
/// Enqueue donation for sequential processing
/// </summary>
private void EnqueueDonation(DonationData 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;
Debug.Log($"[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);
}
}
/// <summary>
/// Process donation queue sequentially with fixed delay
/// </summary>
private IEnumerator ProcessDonationQueue()
{
isProcessingQueue = true;
while (donationQueue.Count > 0)
{
var donation = donationQueue.Dequeue();
queueCount = donationQueue.Count;
// Trigger events
TriggerDonationEvents(donation);
Debug.Log($"[WefLab] Alert triggered. Waiting {alertDelay:F1}s. Remaining in queue: {donationQueue.Count}");
// Wait for fixed delay
yield return new WaitForSeconds(alertDelay);
}
isProcessingQueue = false;
queueProcessorCoroutine = null;
}
/// <summary>
/// Trigger donation events based on amount range
/// </summary>
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}");
}
}
/// <summary>
/// Coroutine to invoke event multiple times with delay
/// </summary>
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
/// <summary>
/// Send Socket.IO CONNECT message (40)
/// </summary>
private void SendSocketConnect()
{
Debug.Log("[WefLab] Sending Socket.IO CONNECT (40)");
SendMessage("40");
}
/// <summary>
/// Send join message to subscribe to donation events
/// </summary>
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);
}
/// <summary>
/// Send raw message through WebSocket
/// </summary>
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
/// <summary>
/// Enqueue action to be executed on main thread
/// </summary>
private void EnqueueMainThreadAction(Action action)
{
lock (actionLock)
{
mainThreadActions.Enqueue(action);
}
}
#endregion
#region Public Methods
/// <summary>
/// Reconnect to WebSocket
/// </summary>
public void Reconnect()
{
Disconnect();
Connect();
}
/// <summary>
/// Check if connected
/// </summary>
public bool IsConnected()
{
return isConnected && ws != null && ws.IsAlive;
}
/// <summary>
/// Get current queue count
/// </summary>
public int GetQueueCount()
{
return donationQueue.Count;
}
/// <summary>
/// Check if queue is being processed
/// </summary>
public bool IsQueueProcessing()
{
return isProcessingQueue;
}
/// <summary>
/// Clear all pending donations in queue
/// </summary>
public void ClearQueue()
{
donationQueue.Clear();
queueCount = 0;
Debug.Log("[WefLab] Queue cleared");
}
/// <summary>
/// Skip current alert and move to next in queue
/// </summary>
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");
}
}
/// <summary>
/// Pause queue processing
/// </summary>
public void PauseQueue()
{
if (queueProcessorCoroutine != null)
{
StopCoroutine(queueProcessorCoroutine);
queueProcessorCoroutine = null;
isProcessingQueue = false;
Debug.Log("[WefLab] Queue paused");
}
}
/// <summary>
/// Resume queue processing
/// </summary>
public void ResumeQueue()
{
if (!isProcessingQueue && donationQueue.Count > 0)
{
queueProcessorCoroutine = StartCoroutine(ProcessDonationQueue());
Debug.Log("[WefLab] Queue resumed");
}
}
#endregion
}
}