using System; using System.Collections; using System.IO; using System.Text; using UnityEngine; using UnityEngine.Networking; /// /// Player customer-support client for the admin server. /// Staff routes are not included. /// Call from any behaviour with StartCoroutine. Staff replies arrive when the game polls an open thread. /// public static class SupportClient { public const string DefaultBaseUrl = "https://kkadmin.playpoolstudios.com"; public const int MaxMessageLength = 4000; public const int MaxLogsLength = 2000000; const string TokenPrefsKey = "WalkInvest.SupportPlayerToken"; const string UserIdPrefsKey = "WalkInvest.SupportPlayerUserId"; const string UsernamePrefsKey = "WalkInvest.SupportPlayerUsername"; const string MessageLengthError = "Message must be between 1 and 4000 characters"; const string LogsLengthError = "Logs must be at most 2000000 characters"; const string PlayerConnectionError = "Please check your internet connection and try again."; const int RequestTimeoutSeconds = 20; static readonly string[] CategorySlugs = { Category.Bug, Category.Cheating, Category.Withdrawal, Category.Crypto, Category.Other }; /// Admin server origin, with no trailing slash. public static string BaseUrl = DefaultBaseUrl; static string _token; static int _userId; static string _username; static bool _loaded; public static bool IsSignedIn { get { EnsureLoaded(); return !string.IsNullOrEmpty(_token); } } public static string Token { get { EnsureLoaded(); return _token; } } public static int UserId { get { EnsureLoaded(); return _userId; } } public static string Username { get { EnsureLoaded(); return _username; } } public static class Category { public const string Bug = "bug"; public const string Cheating = "cheating"; public const string Withdrawal = "withdrawal"; public const string Crypto = "crypto"; public const string Other = "other"; } public static class Status { public const string Open = "open"; public const string Closed = "closed"; } public static class AuthorKind { public const string Player = "player"; public const string Staff = "staff"; } public static bool IsCategory(string slug) { if (string.IsNullOrEmpty(slug)) return false; for (int i = 0; i < CategorySlugs.Length; i++) { if (CategorySlugs[i] == slug) return true; } return false; } public static void ClearSession() { _loaded = true; _token = ""; _userId = 0; _username = ""; PlayerPrefs.DeleteKey(TokenPrefsKey); PlayerPrefs.DeleteKey(UserIdPrefsKey); PlayerPrefs.DeleteKey(UsernamePrefsKey); PlayerPrefs.Save(); } /// /// Drops a stored support session that belongs to a different game username. /// public static void ClearSessionIfDifferentUser(string gameUsername) { EnsureLoaded(); if (string.IsNullOrEmpty(_token) || string.IsNullOrEmpty(gameUsername)) return; if (string.Equals(_username, gameUsername.Trim(), StringComparison.Ordinal)) return; ClearSession(); } /// POST /api/support/player/login. Stores the bearer token for later calls. public static IEnumerator Login(string username, string password, Action> onComplete) { var body = new LoginRequest { username = username != null ? username.Trim() : "", password = password ?? "" }; string json = JsonUtility.ToJson(body); HttpOutcome http = null; yield return Request("POST", "/api/support/player/login", json, false, r => http = r); SupportCallResult result = ParseOk(http); if (result.ok && (result.data == null || string.IsNullOrEmpty(result.data.token))) result = Fail(http.statusCode, "Invalid response from server"); if (result.ok) StoreSession(result.data); LogFailure("/api/support/player/login", result); onComplete?.Invoke(result); } /// POST /api/support/player/devices. Stores this install's Firebase Installation ID. public static IEnumerator RegisterDevice(string fid, string platform, Action> onComplete) { yield return SendDevice("POST", fid, platform, onComplete); } /// DELETE /api/support/player/devices. Drops this install's Firebase Installation ID. public static IEnumerator UnregisterDevice(string fid, Action> onComplete) { yield return SendDevice("DELETE", fid, "android", onComplete); } static IEnumerator SendDevice(string method, string fid, string platform, Action> onComplete) { fid = fid != null ? fid.Trim() : ""; if (string.IsNullOrEmpty(fid)) { var invalid = Fail(400, "Invalid installation id"); LogFailure("/api/support/player/devices", invalid); onComplete?.Invoke(invalid); yield break; } string json = JsonUtility.ToJson(new DeviceRequest { fid = fid, platform = string.IsNullOrEmpty(platform) ? "android" : platform.Trim() }); HttpOutcome http = null; yield return Request(method, "/api/support/player/devices", json, true, r => http = r); if (http != null && http.statusCode == 401 && IsSignedIn) ClearSession(); SupportCallResult result = ParseAck(http); LogFailure("/api/support/player/devices", result); onComplete?.Invoke(result); } /// GET /api/support/player/tickets. Open tickets first, then newest message. public static IEnumerator ListTickets(Action> onComplete) { yield return SendAuthorized("/api/support/player/tickets", "GET", null, onComplete, NormalizeTickets); } /// /// POST /api/support/player/tickets. Each call creates a new open ticket. /// is the Unity player log, sent on create only. Null or whitespace omits it. /// public static IEnumerator CreateTicket(string category, string body, string logs, Action> onComplete) { category = category != null ? category.Trim() : ""; if (!IsCategory(category)) { var invalid = Fail(400, "Invalid category"); LogFailure("/api/support/player/tickets", invalid); onComplete?.Invoke(invalid); yield break; } body = body != null ? body.Trim() : ""; if (!IsMessageLengthValid(body)) { var invalid = Fail(400, MessageLengthError); LogFailure("/api/support/player/tickets", invalid); onComplete?.Invoke(invalid); yield break; } logs = string.IsNullOrWhiteSpace(logs) ? null : logs.Trim(); if (logs != null && logs.Length > MaxLogsLength) { var invalid = Fail(400, LogsLengthError); LogFailure("/api/support/player/tickets", invalid); onComplete?.Invoke(invalid); yield break; } string json = JsonUtility.ToJson(new CreateTicketRequest { category = category, body = body, logs = logs }); yield return SendAuthorized("/api/support/player/tickets", "POST", json, onComplete, NormalizeThread); } /// /// Reads for a new ticket. /// Returns null when the log is missing or empty. Keeps the newest text when the file is longer than . /// public static string ReadUnityPlayerLog() { string path = Application.consoleLogPath; if (string.IsNullOrWhiteSpace(path)) return null; try { using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) { bool truncated = false; long maxBytes = (long)MaxLogsLength * 4; if (stream.Length > maxBytes) { stream.Seek(-maxBytes, SeekOrigin.End); truncated = true; } using (var reader = new StreamReader(stream, Encoding.UTF8, true, 4096, true)) { string text; if (truncated) { string partial = reader.ReadLine(); string rest = reader.ReadToEnd(); text = string.IsNullOrEmpty(rest) ? partial : rest; } else { text = reader.ReadToEnd(); } if (string.IsNullOrWhiteSpace(text)) return null; text = text.Trim(); if (text.Length > MaxLogsLength) text = text.Substring(text.Length - MaxLogsLength); return text; } } } catch (Exception e) { Debug.LogWarning("[SupportClient] could not read Unity log: " + e.Message); return null; } } /// GET /api/support/player/tickets/{ticketId}. Messages are oldest first. public static IEnumerator GetThread(string ticketId, Action> onComplete) { if (!TryTicketPath(ticketId, "", out string path, out SupportCallResult invalid)) { LogFailure("/api/support/player/tickets/{ticketId}", invalid); onComplete?.Invoke(invalid); yield break; } yield return SendAuthorized(path, "GET", null, onComplete, NormalizeThread); } /// /// POST /api/support/player/tickets/{ticketId}/messages. /// A player message on a closed ticket reopens it. /// public static IEnumerator SendMessage(string ticketId, string body, Action> onComplete) { if (!TryTicketPath(ticketId, "/messages", out string path, out SupportCallResult invalid)) { LogFailure("/api/support/player/tickets/{ticketId}/messages", invalid); onComplete?.Invoke(invalid); yield break; } body = body != null ? body.Trim() : ""; if (!IsMessageLengthValid(body)) { invalid = Fail(400, MessageLengthError); LogFailure(path, invalid); onComplete?.Invoke(invalid); yield break; } string json = JsonUtility.ToJson(new MessageRequest { body = body }); yield return SendAuthorized(path, "POST", json, onComplete, NormalizeThread); } /// /// Reads a thread immediately, then again after each interval, until the coroutine is stopped or the token is rejected. /// public static IEnumerator PollThread(string ticketId, float intervalSeconds, Action> onResult) { if (intervalSeconds <= 0f) intervalSeconds = 5f; while (true) { SupportCallResult result = null; yield return GetThread(ticketId, r => result = r); onResult?.Invoke(result); if (result != null && (result.statusCode == 400 || result.statusCode == 401 || result.statusCode == 404)) yield break; yield return new WaitForSecondsRealtime(intervalSeconds); } } static IEnumerator SendAuthorized( string path, string method, string json, Action> onComplete, Action normalize) where T : class { HttpOutcome http = null; yield return Request(method, path, json, true, r => http = r); if (http != null && http.statusCode == 401 && IsSignedIn) ClearSession(); SupportCallResult result = ParseOk(http); if (result.ok && result.data != null) normalize?.Invoke(result.data); LogFailure(path, result); onComplete?.Invoke(result); } static IEnumerator Request(string method, string path, string json, bool authorize, Action done) { if (authorize && string.IsNullOrEmpty(Token)) { done?.Invoke(new HttpOutcome { statusCode = 401, body = "{\"error\":\"Unauthorized\"}" }); yield break; } string url = RootUrl() + path; using (var request = new UnityWebRequest(url, method)) { request.downloadHandler = new DownloadHandlerBuffer(); request.timeout = json != null && json.Length > 64 * 1024 ? 120 : RequestTimeoutSeconds; if (json != null || method == UnityWebRequest.kHttpVerbPOST) { byte[] raw = Encoding.UTF8.GetBytes(json ?? "{}"); request.uploadHandler = new UploadHandlerRaw(raw); request.SetRequestHeader("Content-Type", "application/json"); } if (authorize) request.SetRequestHeader("Authorization", "Bearer " + Token); yield return request.SendWebRequest(); string text = ""; try { text = request.downloadHandler != null ? request.downloadHandler.text ?? "" : ""; } catch (Exception e) { Logger.Log("Support response unreadable: " + e.Message); } if (request.responseCode == 0) { Debug.LogWarning("[SupportClient] failed " + method + " " + url + " — " + (string.IsNullOrEmpty(request.error) ? "network error" : request.error)); done?.Invoke(new HttpOutcome { statusCode = 0, transportError = PlayerConnectionError }); yield break; } done?.Invoke(new HttpOutcome { statusCode = request.responseCode, body = text }); } } static SupportCallResult ParseOk(HttpOutcome http) where T : class { if (http == null) return Fail(0, PlayerConnectionError); if (!string.IsNullOrEmpty(http.transportError)) return Fail(http.statusCode, http.transportError); if (http.statusCode < 200 || http.statusCode >= 300) { SupportErrorBody err = TryParse(http.body); int retry = http.statusCode == 429 && err != null ? err.retryAfterSeconds : 0; string message = err != null && !string.IsNullOrEmpty(err.error) ? err.error : FallbackError(http.statusCode); return Fail(http.statusCode, message, retry); } T data = TryParse(http.body); if (data == null) return Fail(http.statusCode, "Invalid response from server"); return new SupportCallResult { ok = true, statusCode = http.statusCode, data = data }; } /// 2xx with an empty body still counts as success. static SupportCallResult ParseAck(HttpOutcome http) where T : class, new() { SupportCallResult parsed = ParseOk(http); if (parsed.ok || http == null || http.statusCode < 200 || http.statusCode >= 300) return parsed; if (parsed.error != "Invalid response from server") return parsed; return new SupportCallResult { ok = true, statusCode = http.statusCode, data = new T() }; } static SupportCallResult Fail(long statusCode, string error, int retryAfterSeconds = 0) { return new SupportCallResult { ok = false, statusCode = statusCode, error = error, retryAfterSeconds = retryAfterSeconds }; } static T TryParse(string json) where T : class { if (string.IsNullOrEmpty(json)) return null; try { return JsonUtility.FromJson(json); } catch (Exception e) { Logger.Log("Support JSON error: " + e.Message); return null; } } static string FallbackError(long statusCode) { if (statusCode == 401) return "Unauthorized"; if (statusCode == 503) return "Support is unavailable. Please try again later."; return "Something went wrong. Please try again."; } static bool IsMessageLengthValid(string body) { return body != null && body.Length >= 1 && body.Length <= MaxMessageLength; } static bool TryTicketPath(string ticketId, string suffix, out string path, out SupportCallResult error) { if (string.IsNullOrWhiteSpace(ticketId)) { path = null; error = Fail(400, "Invalid ticket id"); return false; } path = "/api/support/player/tickets/" + UnityWebRequest.EscapeURL(ticketId.Trim()) + suffix; error = null; return true; } static void NormalizeTickets(SupportTicketListResponse list) { if (list != null && list.tickets == null) list.tickets = Array.Empty(); } static void NormalizeThread(SupportThread thread) { if (thread != null && thread.messages == null) thread.messages = Array.Empty(); } static void StoreSession(SupportLoginResponse login) { _loaded = true; _token = login.token; _userId = login.user != null ? login.user.id : 0; _username = login.user != null ? login.user.username : ""; PlayerPrefs.SetString(TokenPrefsKey, _token); PlayerPrefs.SetInt(UserIdPrefsKey, _userId); PlayerPrefs.SetString(UsernamePrefsKey, _username ?? ""); PlayerPrefs.Save(); } static void EnsureLoaded() { if (_loaded) return; _loaded = true; _token = PlayerPrefs.GetString(TokenPrefsKey, ""); _userId = PlayerPrefs.GetInt(UserIdPrefsKey, 0); _username = PlayerPrefs.GetString(UsernamePrefsKey, ""); } static string RootUrl() { string root = string.IsNullOrWhiteSpace(BaseUrl) ? DefaultBaseUrl : BaseUrl.Trim(); return root.TrimEnd('/'); } static void LogFailure(string path, SupportCallResult result) { if (result == null || result.ok) return; Logger.Log("Support " + path + " failed (" + result.statusCode + "): " + result.error); } class HttpOutcome { public long statusCode; public string body; public string transportError; } [Serializable] public class LoginRequest { public string username; public string password; } [Serializable] public class CreateTicketRequest { public string category; public string body; public string logs; } [Serializable] public class MessageRequest { public string body; } [Serializable] class DeviceRequest { public string fid; public string platform; } [Serializable] public class SupportErrorBody { public string error; public int retryAfterSeconds; } } public class SupportCallResult { public bool ok; public long statusCode; public string error; public int retryAfterSeconds; public T data; } [Serializable] public class SupportLoginResponse { public string token; public SupportPlayer user; } [Serializable] public class SupportPlayer { public int id; public string username; } [Serializable] public class SupportTicket { public string id; public int userId; public string username; public string category; public string categoryLabel; public string status; public string createdAt; public string updatedAt; public string lastMessageAt; public string lastMessagePreview; public bool hasLogs; } [Serializable] public class SupportMessage { public string id; public string ticketId; public string authorKind; public string authorName; public string body; public string createdAt; } [Serializable] public class SupportThread { public SupportTicket ticket; public SupportMessage[] messages; } [Serializable] public class SupportTicketListResponse { public SupportTicket[] tickets; } [Serializable] public class SupportDeviceResponse { }