680 lines
21 KiB
C#
680 lines
21 KiB
C#
using System;
|
|
using System.Collections;
|
|
using System.IO;
|
|
using System.Text;
|
|
using UnityEngine;
|
|
using UnityEngine.Networking;
|
|
|
|
/// <summary>
|
|
/// Player customer-support client for the admin server.
|
|
/// Staff routes are not included.
|
|
/// Call from any behaviour with <c>StartCoroutine</c>. Staff replies arrive when the game polls an open thread.
|
|
/// </summary>
|
|
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
|
|
};
|
|
|
|
/// <summary>Admin server origin, with no trailing slash.</summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drops a stored support session that belongs to a different game username.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>POST /api/support/player/login. Stores the bearer token for later calls.</summary>
|
|
public static IEnumerator Login(string username, string password, Action<SupportCallResult<SupportLoginResponse>> 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<SupportLoginResponse> result = ParseOk<SupportLoginResponse>(http);
|
|
if (result.ok && (result.data == null || string.IsNullOrEmpty(result.data.token)))
|
|
result = Fail<SupportLoginResponse>(http.statusCode, "Invalid response from server");
|
|
|
|
if (result.ok)
|
|
StoreSession(result.data);
|
|
|
|
LogFailure("/api/support/player/login", result);
|
|
onComplete?.Invoke(result);
|
|
}
|
|
|
|
/// <summary>POST /api/support/player/devices. Stores this install's Firebase Installation ID.</summary>
|
|
public static IEnumerator RegisterDevice(string fid, string platform, Action<SupportCallResult<SupportDeviceResponse>> onComplete)
|
|
{
|
|
yield return SendDevice("POST", fid, platform, onComplete);
|
|
}
|
|
|
|
/// <summary>DELETE /api/support/player/devices. Drops this install's Firebase Installation ID.</summary>
|
|
public static IEnumerator UnregisterDevice(string fid, Action<SupportCallResult<SupportDeviceResponse>> onComplete)
|
|
{
|
|
yield return SendDevice("DELETE", fid, "android", onComplete);
|
|
}
|
|
|
|
static IEnumerator SendDevice(string method, string fid, string platform, Action<SupportCallResult<SupportDeviceResponse>> onComplete)
|
|
{
|
|
fid = fid != null ? fid.Trim() : "";
|
|
if (string.IsNullOrEmpty(fid))
|
|
{
|
|
var invalid = Fail<SupportDeviceResponse>(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<SupportDeviceResponse> result = ParseAck<SupportDeviceResponse>(http);
|
|
LogFailure("/api/support/player/devices", result);
|
|
onComplete?.Invoke(result);
|
|
}
|
|
|
|
/// <summary>GET /api/support/player/tickets. Open tickets first, then newest message.</summary>
|
|
public static IEnumerator ListTickets(Action<SupportCallResult<SupportTicketListResponse>> onComplete)
|
|
{
|
|
yield return SendAuthorized("/api/support/player/tickets", "GET", null, onComplete, NormalizeTickets);
|
|
}
|
|
|
|
/// <summary>
|
|
/// POST /api/support/player/tickets. Each call creates a new open ticket.
|
|
/// <paramref name="logs"/> is the Unity player log, sent on create only. Null or whitespace omits it.
|
|
/// </summary>
|
|
public static IEnumerator CreateTicket(string category, string body, string logs, Action<SupportCallResult<SupportThread>> onComplete)
|
|
{
|
|
category = category != null ? category.Trim() : "";
|
|
if (!IsCategory(category))
|
|
{
|
|
var invalid = Fail<SupportThread>(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<SupportThread>(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<SupportThread>(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads <see cref="Application.consoleLogPath"/> for a new ticket.
|
|
/// Returns null when the log is missing or empty. Keeps the newest text when the file is longer than <see cref="MaxLogsLength"/>.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>GET /api/support/player/tickets/{ticketId}. Messages are oldest first.</summary>
|
|
public static IEnumerator GetThread(string ticketId, Action<SupportCallResult<SupportThread>> onComplete)
|
|
{
|
|
if (!TryTicketPath(ticketId, "", out string path, out SupportCallResult<SupportThread> invalid))
|
|
{
|
|
LogFailure("/api/support/player/tickets/{ticketId}", invalid);
|
|
onComplete?.Invoke(invalid);
|
|
yield break;
|
|
}
|
|
|
|
yield return SendAuthorized(path, "GET", null, onComplete, NormalizeThread);
|
|
}
|
|
|
|
/// <summary>
|
|
/// POST /api/support/player/tickets/{ticketId}/messages.
|
|
/// A player message on a closed ticket reopens it.
|
|
/// </summary>
|
|
public static IEnumerator SendMessage(string ticketId, string body, Action<SupportCallResult<SupportThread>> onComplete)
|
|
{
|
|
if (!TryTicketPath(ticketId, "/messages", out string path, out SupportCallResult<SupportThread> invalid))
|
|
{
|
|
LogFailure("/api/support/player/tickets/{ticketId}/messages", invalid);
|
|
onComplete?.Invoke(invalid);
|
|
yield break;
|
|
}
|
|
|
|
body = body != null ? body.Trim() : "";
|
|
if (!IsMessageLengthValid(body))
|
|
{
|
|
invalid = Fail<SupportThread>(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads a thread immediately, then again after each interval, until the coroutine is stopped or the token is rejected.
|
|
/// </summary>
|
|
public static IEnumerator PollThread(string ticketId, float intervalSeconds, Action<SupportCallResult<SupportThread>> onResult)
|
|
{
|
|
if (intervalSeconds <= 0f)
|
|
intervalSeconds = 5f;
|
|
|
|
while (true)
|
|
{
|
|
SupportCallResult<SupportThread> 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<T>(
|
|
string path,
|
|
string method,
|
|
string json,
|
|
Action<SupportCallResult<T>> onComplete,
|
|
Action<T> 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<T> result = ParseOk<T>(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<HttpOutcome> 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<T> ParseOk<T>(HttpOutcome http) where T : class
|
|
{
|
|
if (http == null)
|
|
return Fail<T>(0, PlayerConnectionError);
|
|
if (!string.IsNullOrEmpty(http.transportError))
|
|
return Fail<T>(http.statusCode, http.transportError);
|
|
|
|
if (http.statusCode < 200 || http.statusCode >= 300)
|
|
{
|
|
SupportErrorBody err = TryParse<SupportErrorBody>(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<T>(http.statusCode, message, retry);
|
|
}
|
|
|
|
T data = TryParse<T>(http.body);
|
|
if (data == null)
|
|
return Fail<T>(http.statusCode, "Invalid response from server");
|
|
|
|
return new SupportCallResult<T>
|
|
{
|
|
ok = true,
|
|
statusCode = http.statusCode,
|
|
data = data
|
|
};
|
|
}
|
|
|
|
/// <summary>2xx with an empty body still counts as success.</summary>
|
|
static SupportCallResult<T> ParseAck<T>(HttpOutcome http) where T : class, new()
|
|
{
|
|
SupportCallResult<T> parsed = ParseOk<T>(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<T>
|
|
{
|
|
ok = true,
|
|
statusCode = http.statusCode,
|
|
data = new T()
|
|
};
|
|
}
|
|
|
|
static SupportCallResult<T> Fail<T>(long statusCode, string error, int retryAfterSeconds = 0)
|
|
{
|
|
return new SupportCallResult<T>
|
|
{
|
|
ok = false,
|
|
statusCode = statusCode,
|
|
error = error,
|
|
retryAfterSeconds = retryAfterSeconds
|
|
};
|
|
}
|
|
|
|
static T TryParse<T>(string json) where T : class
|
|
{
|
|
if (string.IsNullOrEmpty(json))
|
|
return null;
|
|
try
|
|
{
|
|
return JsonUtility.FromJson<T>(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<SupportThread> error)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(ticketId))
|
|
{
|
|
path = null;
|
|
error = Fail<SupportThread>(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<SupportTicket>();
|
|
}
|
|
|
|
static void NormalizeThread(SupportThread thread)
|
|
{
|
|
if (thread != null && thread.messages == null)
|
|
thread.messages = Array.Empty<SupportMessage>();
|
|
}
|
|
|
|
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<T>(string path, SupportCallResult<T> 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<T>
|
|
{
|
|
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
|
|
{
|
|
}
|