1700 lines
64 KiB
C#
1700 lines
64 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; // 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A single emoticon/sticker referenced in a chat message.
|
|
/// </summary>
|
|
[System.Serializable]
|
|
public class ChatEmoticon
|
|
{
|
|
public string name; // emoticon key/name as it appears in the message
|
|
public string imageUrl; // image URL to render
|
|
}
|
|
|
|
/// <summary>
|
|
/// Chat message data structure. Shares weflab's flat envelope with donations
|
|
/// (type/data) but carries chat-specific fields: color, badges, emoticons, warnings.
|
|
/// </summary>
|
|
[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<ChatEmoticon> emoticons = new List<ChatEmoticon>(); // 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Unity event with donation data parameter
|
|
/// </summary>
|
|
[System.Serializable]
|
|
public class DonationEvent : UnityEvent<DonationData> { }
|
|
|
|
/// <summary>
|
|
/// Unity event with chat data parameter
|
|
/// </summary>
|
|
[System.Serializable]
|
|
public class ChatEvent : UnityEvent<ChatData> { }
|
|
|
|
/// <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";
|
|
|
|
[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<DonationEventTrigger>();
|
|
|
|
[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;
|
|
|
|
/// <summary>Console log that only fires when verboseLog (detail logging) is enabled.</summary>
|
|
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<DonationData> donationQueue = new Queue<DonationData>();
|
|
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 = "";
|
|
|
|
/// <summary>
|
|
/// One WebSocket connection to a single weflab socket server (one per linked platform).
|
|
/// </summary>
|
|
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<PlatformConnection> connections = new List<PlatformConnection>();
|
|
|
|
private string userIdx = ""; // Will be extracted from page (shared by every socket)
|
|
private bool isExtracting = false;
|
|
|
|
// Duplicate-donation guard: signature -> last seen Time.time
|
|
private readonly Dictionary<string, float> recentDonations = new Dictionary<string, float>();
|
|
|
|
// Ping/Pong settings (informational, reported by server handshake)
|
|
private float pingInterval = 30f;
|
|
|
|
// 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}");
|
|
|
|
// 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)");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Capture the loginData fields needed to call the /api/ settings endpoint and the TTS endpoint.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<JObject>(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");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Recursively find a setting value by key within the nested settings object (radio/checkbox/text/...).
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build one PlatformConnection per linked platform from loginData.config.url.
|
|
/// Mirrors the weflab web client which opens socket.iop[platform] for each linked platform.
|
|
/// </summary>
|
|
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))}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Convert a config host (https://ssafreeca.weflab.com) into a full Socket.IO websocket URL
|
|
/// subscribed to the given page ("alert" or "chat").
|
|
/// </summary>
|
|
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";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Open every prepared platform connection.
|
|
/// </summary>
|
|
public void ConnectAll()
|
|
{
|
|
foreach (var conn in connections)
|
|
{
|
|
ConnectOne(conn);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Backwards-compatible alias - connects to all platform sockets.
|
|
/// </summary>
|
|
public void Connect()
|
|
{
|
|
ConnectAll();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Open a single platform connection and wire its event handlers.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Disconnect every platform connection.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Refresh the aggregate inspector fields (isConnected / currentSid) from all connections.
|
|
/// </summary>
|
|
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
|
|
|
|
/// <summary>
|
|
/// Process incoming WebSocket message for a given platform connection
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handle Engine.IO OPEN message (handshake)
|
|
/// </summary>
|
|
private void HandleEngineOpen(PlatformConnection conn, string payload)
|
|
{
|
|
try
|
|
{
|
|
var openData = JsonConvert.DeserializeObject<JObject>(payload);
|
|
conn.engineSid = openData["sid"]?.ToString() ?? "";
|
|
|
|
if (openData["pingInterval"] != null)
|
|
{
|
|
pingInterval = openData["pingInterval"].Value<float>() / 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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handle Engine.IO PING
|
|
/// </summary>
|
|
private void HandlePing(PlatformConnection conn)
|
|
{
|
|
VLog($"[WefLab] ({conn.Label}) Received PING, sending PONG");
|
|
SendMessage(conn, "3"); // Send PONG
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handle Socket.IO CONNECT response
|
|
/// </summary>
|
|
private void HandleSocketConnect(PlatformConnection conn, string payload)
|
|
{
|
|
try
|
|
{
|
|
if (!string.IsNullOrEmpty(payload))
|
|
{
|
|
var connectData = JsonConvert.DeserializeObject<JObject>(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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Send the "platform" feed message that starts/maintains donation delivery on a platform socket.
|
|
/// Mirrors weflab: socket.send(iop, { type:"platform", start, platform:<obj>, use:[] }).
|
|
/// </summary>
|
|
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<string>(),
|
|
// 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Periodically re-send the platform feed message to keep the donation feed alive (every 60s).
|
|
/// </summary>
|
|
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<string> NonMonetarySubtypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"subscribe", "vip", "follow", "follow_item", "follow_item_effect", "up", "emoticon", "chat"
|
|
};
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private void HandleSocketEvent(PlatformConnection conn, string payload)
|
|
{
|
|
try
|
|
{
|
|
var eventArray = JsonConvert.DeserializeObject<JArray>(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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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)
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handle an "alert" payload (a monetary donation on any platform).
|
|
/// </summary>
|
|
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<long>() ?? 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handle a "chat" payload. Structured and wired through onChatReceived for later use;
|
|
/// no-op unless enableChat is turned on.
|
|
/// </summary>
|
|
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<long>() ?? 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<bool>();
|
|
|
|
// Non-null, non-bool (e.g. a url string) => present/true
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extract platform badge ids from data.grade.badge (an array of strings).
|
|
/// </summary>
|
|
private static string[] ParseBadges(JObject grade)
|
|
{
|
|
if (grade?["badge"] is not JArray badgeArray || badgeArray.Count == 0)
|
|
return Array.Empty<string>();
|
|
|
|
var list = new List<string>();
|
|
foreach (var b in badgeArray)
|
|
{
|
|
string s = b?.ToString();
|
|
if (!string.IsNullOrEmpty(s))
|
|
list.Add(s);
|
|
}
|
|
return list.ToArray();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parse data.emoticon into a flat list. weflab uses a map { name: [count, imageUrl, type], ... };
|
|
/// an array form [ [name?, imageUrl], ... ] is also handled defensively.
|
|
/// </summary>
|
|
private static List<ChatEmoticon> ParseEmoticons(JToken token)
|
|
{
|
|
var result = new List<ChatEmoticon>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enqueue donation for sequential processing
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<string>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Result holder for the async TTS fetch.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Replicate weflab's TTS text: the alert template with tokens replaced, plus the donor message.
|
|
/// </summary>
|
|
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
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <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(PlatformConnection conn)
|
|
{
|
|
VLog($"[WefLab] ({conn.Label}) Sending Socket.IO CONNECT (40)");
|
|
SendMessage(conn, "40");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Send raw message through a specific platform connection
|
|
/// </summary>
|
|
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
|
|
|
|
/// <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()
|
|
{
|
|
foreach (var conn in connections)
|
|
{
|
|
if (conn.connected && conn.ws != null && conn.ws.IsAlive)
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// <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
|
|
}
|
|
}
|
|
|