customer support WiP

This commit is contained in:
2026-10-16 14:00:52 +05:30
parent 053665c690
commit a1b2b41912
35 changed files with 14855 additions and 349 deletions
+655
View File
@@ -0,0 +1,655 @@
using System;
using System.Collections;
using System.Collections.Generic;
using DG.Tweening;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
public class CustomerSupportCanvas : MonoBehaviour
{
const float ScreenFadeSeconds = 0.5f;
const float PollIntervalSeconds = 5f;
static readonly (string slug, string label)[] Categories =
{
(SupportClient.Category.Bug, "Report Bug"),
(SupportClient.Category.Cheating, "Report cheating"),
(SupportClient.Category.Withdrawal, "Request withdrawal"),
(SupportClient.Category.Crypto, "Pay with crypto"),
(SupportClient.Category.Other, "Other"),
};
public CanvasGroup helpScreen;
public Button btnCloseHelp;
public Button btnFaq, btnTickets;
public string faqUrl = "https://kickkings.app/faq";
public CanvasGroup ticketScreen;
public Button btnCloseTicket;
/// <summary>
/// If 0 open tickets, "No open tickets", otherwise "X Open Tickets"
/// </summary>
public TMP_Text txtTicketTitle;
public RectTransform ticketListParent;
/// <summary>
/// Item for the ticket list, single TMP_Text inside. Which should be like "Ticket #001", Once clicked, Show the necessary chat.
/// </summary>
public GameObject ticketListItemPrefab;
public Sprite spriteTicketOpen, spriteTicketClosed;
/// <summary>
/// If no chat is selected, show this text, Also dont show the input field if this is shown.
/// </summary>
public GameObject noChatSelectedTxt;
public RectTransform ticketChatParent;
/// <summary>
/// Two children, first is left, second is right, only 1 TMP_Text inside each, which should be the message. enable left for customer support agent, enable right for player.
/// </summary>
public GameObject ticketChatItemPrefab;
public TMP_InputField inputField;
public Button btnSend;
public Button newTicketBtn;
public GameObject newTicketPanel;
public RectTransform ticketReasonListParent;
/// <summary>
/// Item for the ticket reason list, a Toggle with a TMP_Text inside, which should be like "Reason 1", "Reason 2", "Reason 3", etc. Only 1 can be on. By default it will be all off, If all off, create Button should be interactable=false;
/// </summary>
public GameObject ticketReasonItemPrefab;
public Button btnCreateTicket;
public Button btnCancelTicket;
readonly List<SupportTicket> _tickets = new List<SupportTicket>();
readonly List<Toggle> _reasonToggles = new List<Toggle>();
readonly List<string> _reasonSlugs = new List<string>();
string _selectedTicketId;
string _selectedCategorySlug;
string _selectedCategoryLabel;
Coroutine _pollRoutine;
Coroutine _busyRoutine;
bool _requestInFlight;
bool _reasonsBuilt;
ToggleGroup _reasonToggleGroup;
void Start()
{
if (btnFaq != null)
btnFaq.onClick.AddListener(LaunchFAQ);
if (btnTickets != null)
btnTickets.onClick.AddListener(ShowTicketScreen);
if (btnCloseHelp != null)
btnCloseHelp.onClick.AddListener(CloseHelp);
if (btnCloseTicket != null)
btnCloseTicket.onClick.AddListener(ShowHelpScreen);
if (btnSend != null)
btnSend.onClick.AddListener(OnSendClicked);
if (newTicketBtn != null)
newTicketBtn.onClick.AddListener(OpenNewTicketPanel);
if (btnCreateTicket != null)
btnCreateTicket.onClick.AddListener(OnCreateTicketClicked);
if (btnCancelTicket != null)
btnCancelTicket.onClick.AddListener(CloseNewTicketPanel);
SetCanvasGroupVisible(helpScreen, true, false);
SetCanvasGroupVisible(ticketScreen, false, false);
ClearChatSelection();
HideNewTicketPanel();
EnsureReasonToggles();
}
void OnDisable()
{
StopPoll();
if (_busyRoutine != null)
{
StopCoroutine(_busyRoutine);
_busyRoutine = null;
}
_requestInFlight = false;
}
public void ShowHelpScreen()
{
StopPoll();
ClearChatSelection();
HideNewTicketPanel();
SetCanvasGroupVisible(helpScreen, true, true);
SetCanvasGroupVisible(ticketScreen, false, true);
}
public void ShowTicketScreen()
{
HideNewTicketPanel();
ClearChatSelection();
SetCanvasGroupVisible(helpScreen, false, true);
SetCanvasGroupVisible(ticketScreen, true, true);
RunExclusive(RefreshTicketsFlow());
}
void CloseHelp()
{
StopPoll();
ClearChatSelection();
HideNewTicketPanel();
SetCanvasGroupVisible(helpScreen, false, true);
SetCanvasGroupVisible(ticketScreen, false, true);
gameObject.SetActive(false);
}
void LaunchFAQ()
{
Application.OpenURL(faqUrl);
}
void OpenNewTicketPanel()
{
EnsureReasonToggles();
ResetReasonToggles();
if (newTicketPanel != null)
newTicketPanel.SetActive(true);
UpdateCreateButtonInteractable();
}
void CloseNewTicketPanel()
{
HideNewTicketPanel();
}
void HideNewTicketPanel()
{
if (newTicketPanel != null)
newTicketPanel.SetActive(false);
ResetReasonToggles();
}
void OnSendClicked()
{
if (_requestInFlight || string.IsNullOrEmpty(_selectedTicketId))
return;
string body = inputField != null ? inputField.text.Trim() : "";
if (string.IsNullOrEmpty(body))
return;
RunExclusive(SendMessageFlow(body));
}
void OnCreateTicketClicked()
{
if (_requestInFlight || string.IsNullOrEmpty(_selectedCategorySlug))
return;
string body = _selectedCategorySlug == SupportClient.Category.Other
? "Hi I need assistance"
: "Hi, I need assistance with " + _selectedCategoryLabel;
RunExclusive(CreateTicketFlow(_selectedCategorySlug, body));
}
IEnumerator RefreshTicketsFlow()
{
bool ok = false;
yield return EnsureSupportAuth(r => ok = r);
if (!ok)
yield break;
SupportCallResult<SupportTicketListResponse> result = null;
yield return SupportClient.ListTickets(r => result = r);
if (result == null || !result.ok)
{
ShowError(result != null ? result.error : "Could not load tickets.");
yield break;
}
RebuildTicketList(result.data != null ? result.data.tickets : null);
}
IEnumerator SendMessageFlow(string body)
{
bool ok = false;
yield return EnsureSupportAuth(r => ok = r);
if (!ok)
yield break;
string ticketId = _selectedTicketId;
SupportCallResult<SupportThread> result = null;
yield return SupportClient.SendMessage(ticketId, body, r => result = r);
if (result == null || !result.ok)
{
ShowError(result != null ? result.error : "Could not send message.");
yield break;
}
if (inputField != null)
inputField.text = "";
ApplyThread(result.data);
RefreshTicketPreviewFromThread(result.data);
RebuildTicketListFromCache();
}
IEnumerator CreateTicketFlow(string category, string body)
{
bool ok = false;
yield return EnsureSupportAuth(r => ok = r);
if (!ok)
yield break;
SupportCallResult<SupportThread> result = null;
yield return SupportClient.CreateTicket(category, body, r => result = r);
if (result == null || !result.ok)
{
ShowError(result != null ? result.error : "Could not create ticket.");
yield break;
}
HideNewTicketPanel();
SupportCallResult<SupportTicketListResponse> list = null;
yield return SupportClient.ListTickets(r => list = r);
if (list != null && list.ok)
RebuildTicketList(list.data != null ? list.data.tickets : null);
else if (result.data != null && result.data.ticket != null)
{
_tickets.Clear();
_tickets.Add(result.data.ticket);
RebuildTicketListFromCache();
}
if (result.data != null && result.data.ticket != null)
SelectTicket(result.data.ticket.id, result.data);
}
IEnumerator EnsureSupportAuth(Action<bool> done)
{
if (SupportClient.IsSignedIn)
{
done?.Invoke(true);
yield break;
}
string username = !string.IsNullOrEmpty(LoginManager.SessionUsername)
? LoginManager.SessionUsername
: (LoginManager.CurrentUser != null ? LoginManager.CurrentUser.username : null);
string password = LoginManager.SessionPassword;
Debug.Log("[CustomerSupport] support login as user='" + (username ?? "") +
"' passwordLength=" + (password != null ? password.Length : 0));
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
{
ShowError("Please sign in again to use customer support.");
done?.Invoke(false);
yield break;
}
SupportCallResult<SupportLoginResponse> result = null;
yield return SupportClient.Login(username, password, r => result = r);
if (result == null || !result.ok)
{
ShowError(result != null ? result.error : "Support login failed.");
done?.Invoke(false);
yield break;
}
done?.Invoke(true);
}
IEnumerator PollSelectedTicket()
{
string ticketId = _selectedTicketId;
if (string.IsNullOrEmpty(ticketId))
yield break;
yield return SupportClient.PollThread(ticketId, PollIntervalSeconds, result =>
{
if (result == null || !result.ok)
return;
if (_selectedTicketId != ticketId)
return;
ApplyThread(result.data);
RefreshTicketPreviewFromThread(result.data);
});
}
void SelectTicket(string ticketId, SupportThread initialThread = null)
{
if (string.IsNullOrEmpty(ticketId))
return;
_selectedTicketId = ticketId;
if (noChatSelectedTxt != null)
noChatSelectedTxt.SetActive(false);
SetChatInputVisible(true);
if (initialThread != null)
ApplyThread(initialThread);
else
ClearChatMessages();
StopPoll();
_pollRoutine = StartCoroutine(PollSelectedTicket());
}
void ClearChatSelection()
{
StopPoll();
_selectedTicketId = null;
ClearChatMessages();
if (noChatSelectedTxt != null)
noChatSelectedTxt.SetActive(true);
SetChatInputVisible(false);
if (inputField != null)
inputField.text = "";
}
void SetChatInputVisible(bool visible)
{
if (inputField != null)
inputField.gameObject.SetActive(visible);
if (btnSend != null)
btnSend.gameObject.SetActive(visible);
}
void RebuildTicketList(SupportTicket[] tickets)
{
_tickets.Clear();
if (tickets != null)
{
for (int i = 0; i < tickets.Length; i++)
{
if (tickets[i] != null)
_tickets.Add(tickets[i]);
}
}
RebuildTicketListFromCache();
}
void RebuildTicketListFromCache()
{
ClearChildren(ticketListParent);
UpdateOpenTicketTitle();
if (ticketListItemPrefab == null || ticketListParent == null)
return;
for (int i = 0; i < _tickets.Count; i++)
{
SupportTicket ticket = _tickets[i];
if (ticket == null)
continue;
GameObject item = Instantiate(ticketListItemPrefab, ticketListParent);
item.SetActive(true);
TMP_Text label = item.GetComponentInChildren<TMP_Text>(true);
if (label != null)
label.text = "Ticket #" + (i + 1).ToString("000");
Image image = item.GetComponent<Image>();
if (image == null)
image = item.GetComponentInChildren<Image>(true);
if (image != null)
{
bool isOpen = ticket.status == SupportClient.Status.Open;
Sprite sprite = isOpen ? spriteTicketOpen : spriteTicketClosed;
if (sprite != null)
image.sprite = sprite;
}
Button button = item.GetComponent<Button>();
if (button == null)
button = item.GetComponentInChildren<Button>(true);
if (button == null)
{
button = item.AddComponent<Button>();
if (image != null)
button.targetGraphic = image;
}
string ticketId = ticket.id;
button.onClick.RemoveAllListeners();
button.onClick.AddListener(() => SelectTicket(ticketId));
}
}
void ApplyThread(SupportThread thread)
{
if (thread == null)
return;
ClearChatMessages();
if (ticketChatItemPrefab == null || ticketChatParent == null || thread.messages == null)
return;
for (int i = 0; i < thread.messages.Length; i++)
{
SupportMessage message = thread.messages[i];
if (message == null)
continue;
GameObject item = Instantiate(ticketChatItemPrefab, ticketChatParent);
item.SetActive(true);
bool isStaff = string.Equals(message.authorKind, SupportClient.AuthorKind.Staff, StringComparison.OrdinalIgnoreCase);
Transform left = item.transform.childCount > 0 ? item.transform.GetChild(0) : null;
Transform right = item.transform.childCount > 1 ? item.transform.GetChild(1) : null;
if (left != null)
left.gameObject.SetActive(isStaff);
if (right != null)
right.gameObject.SetActive(!isStaff);
Transform activeSide = isStaff ? left : right;
if (activeSide != null)
{
TMP_Text text = activeSide.GetComponentInChildren<TMP_Text>(true);
if (text != null)
text.text = message.body ?? "";
}
}
}
void ClearChatMessages()
{
ClearChildren(ticketChatParent);
}
void RefreshTicketPreviewFromThread(SupportThread thread)
{
if (thread == null || thread.ticket == null || string.IsNullOrEmpty(thread.ticket.id))
return;
bool found = false;
for (int i = 0; i < _tickets.Count; i++)
{
if (_tickets[i] != null && _tickets[i].id == thread.ticket.id)
{
_tickets[i] = thread.ticket;
found = true;
break;
}
}
if (!found)
_tickets.Insert(0, thread.ticket);
UpdateOpenTicketTitle();
}
void UpdateOpenTicketTitle()
{
if (txtTicketTitle == null)
return;
int openCount = 0;
for (int i = 0; i < _tickets.Count; i++)
{
if (_tickets[i] != null && _tickets[i].status == SupportClient.Status.Open)
openCount++;
}
txtTicketTitle.text = openCount == 0
? "No open tickets"
: openCount + (openCount == 1 ? " Open Ticket" : " Open Tickets");
}
void EnsureReasonToggles()
{
if (_reasonsBuilt || ticketReasonListParent == null || ticketReasonItemPrefab == null)
return;
_reasonsBuilt = true;
ClearChildren(ticketReasonListParent);
_reasonToggles.Clear();
_reasonSlugs.Clear();
_reasonToggleGroup = ticketReasonListParent.GetComponent<ToggleGroup>();
if (_reasonToggleGroup == null)
_reasonToggleGroup = ticketReasonListParent.gameObject.AddComponent<ToggleGroup>();
_reasonToggleGroup.allowSwitchOff = true;
for (int i = 0; i < Categories.Length; i++)
{
string slug = Categories[i].slug;
string label = Categories[i].label;
GameObject item = Instantiate(ticketReasonItemPrefab, ticketReasonListParent);
item.SetActive(true);
Toggle toggle = item.GetComponent<Toggle>();
if (toggle == null)
toggle = item.GetComponentInChildren<Toggle>(true);
if (toggle == null)
continue;
toggle.group = _reasonToggleGroup;
toggle.isOn = false;
toggle.onValueChanged.RemoveAllListeners();
TMP_Text text = item.GetComponentInChildren<TMP_Text>(true);
if (text != null)
text.text = label;
string capturedSlug = slug;
string capturedLabel = label;
toggle.onValueChanged.AddListener(isOn =>
{
if (isOn)
{
_selectedCategorySlug = capturedSlug;
_selectedCategoryLabel = capturedLabel;
}
else if (_selectedCategorySlug == capturedSlug)
{
_selectedCategorySlug = null;
_selectedCategoryLabel = null;
}
UpdateCreateButtonInteractable();
});
_reasonToggles.Add(toggle);
_reasonSlugs.Add(slug);
}
UpdateCreateButtonInteractable();
}
void ResetReasonToggles()
{
_selectedCategorySlug = null;
_selectedCategoryLabel = null;
for (int i = 0; i < _reasonToggles.Count; i++)
{
if (_reasonToggles[i] != null)
_reasonToggles[i].SetIsOnWithoutNotify(false);
}
UpdateCreateButtonInteractable();
}
void UpdateCreateButtonInteractable()
{
if (btnCreateTicket != null)
btnCreateTicket.interactable = !_requestInFlight && !string.IsNullOrEmpty(_selectedCategorySlug);
}
void RunExclusive(IEnumerator routine)
{
if (_requestInFlight)
return;
if (_busyRoutine != null)
StopCoroutine(_busyRoutine);
_busyRoutine = StartCoroutine(RunExclusiveCoroutine(routine));
}
IEnumerator RunExclusiveCoroutine(IEnumerator routine)
{
_requestInFlight = true;
SetBusyUi(true);
try
{
yield return routine;
}
finally
{
_requestInFlight = false;
_busyRoutine = null;
SetBusyUi(false);
}
}
void SetBusyUi(bool busy)
{
if (btnSend != null)
btnSend.interactable = !busy && !string.IsNullOrEmpty(_selectedTicketId);
if (newTicketBtn != null)
newTicketBtn.interactable = !busy;
if (btnTickets != null)
btnTickets.interactable = !busy;
UpdateCreateButtonInteractable();
if (busy && btnCreateTicket != null)
btnCreateTicket.interactable = false;
}
void StopPoll()
{
if (_pollRoutine != null)
{
StopCoroutine(_pollRoutine);
_pollRoutine = null;
}
}
static void SetCanvasGroupVisible(CanvasGroup group, bool visible, bool animate)
{
if (group == null)
return;
group.DOKill();
group.blocksRaycasts = visible;
group.interactable = visible;
if (animate)
group.DOFade(visible ? 1f : 0f, ScreenFadeSeconds);
else
group.alpha = visible ? 1f : 0f;
}
static void ClearChildren(Transform parent)
{
if (parent == null)
return;
for (int i = parent.childCount - 1; i >= 0; i--)
Destroy(parent.GetChild(i).gameObject);
}
static void ShowError(string message)
{
if (string.IsNullOrEmpty(message))
message = "Something went wrong. Please try again.";
if (MessageBoxDialog.IsAvailable)
MessageBoxDialog.ShowBasic(message);
else
Debug.LogWarning(message);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3eb20b333998c3513aee8b47c7ebc472
+27
View File
@@ -64,9 +64,16 @@ public class LoginManager : MonoBehaviour
bool _appFocused = true;
bool _handlingKeepaliveUnauthorized;
string _pendingAuthMessage;
string _pendingSessionPassword;
public static string AuthToken { get; private set; }
/// <summary>Username from the last successful login/register (memory only).</summary>
public static string SessionUsername { get; private set; }
/// <summary>Game password kept in memory only after a successful login/register. Cleared on logout.</summary>
public static string SessionPassword { get; private set; }
/// <summary>Assigned side from the matchmaker <c>your_team</c> field. <c>null</c> until a room is found or after clear.</summary>
public static Team? MatchYourTeam { get; private set; }
@@ -175,6 +182,8 @@ public class LoginManager : MonoBehaviour
// Dev: log in with clone-aware credentials instead of resuming a saved session.
StopKeepalive();
AuthToken = "";
SessionUsername = "";
SessionPassword = "";
PlayerPrefs.DeleteKey(PlayerPrefsAuthKey);
PlayerPrefs.Save();
@@ -185,6 +194,7 @@ public class LoginManager : MonoBehaviour
if (passwordInputLogin != null) passwordInputLogin.text = devPass;
string loginJson = BuildLoginBody(devUser, devPass);
// _pendingSessionPassword = devPass;
// StartCoroutine(AuthPostCoroutine(AuthBaseUrl + "/auth/login", loginJson, "Signed in."));
SetBusy(false);
#else
@@ -250,6 +260,8 @@ public class LoginManager : MonoBehaviour
if (instance != null)
instance.StopKeepalive();
AuthToken = "";
SessionUsername = "";
SessionPassword = "";
ClearLocalUserProfile();
ClearMatchYourTeam();
PlayerPrefs.DeleteKey(PlayerPrefsAuthKey);
@@ -274,10 +286,13 @@ public class LoginManager : MonoBehaviour
}
AuthToken = "";
SessionUsername = "";
SessionPassword = "";
ClearLocalUserProfile();
MainMenuManager.ClearCachedSettingsProfile();
ClearMatchYourTeam();
OnUserDataUpdated = null;
SupportClient.ClearSession();
Cupid.ClearSession();
CupidLobby.ClearUsername();
GameManager.ClearMatchSession();
@@ -390,6 +405,7 @@ public class LoginManager : MonoBehaviour
return;
}
_pendingSessionPassword = password;
StartCoroutine(AuthPostCoroutine(AuthBaseUrl + "/auth/login", BuildLoginBody(username, password), "Signed in."));
}
@@ -427,6 +443,7 @@ public class LoginManager : MonoBehaviour
return;
}
_pendingSessionPassword = password;
StartCoroutine(AuthPostCoroutine(AuthBaseUrl + "/auth/register", BuildRegisterBody(username, email, password), "Account created."));
}
@@ -713,12 +730,16 @@ public class LoginManager : MonoBehaviour
if (profileOk)
{
SessionUsername = _currentUser != null ? _currentUser.username : "";
SessionPassword = _pendingSessionPassword ?? "";
_pendingSessionPassword = null;
enteredMenu = true;
StartKeepalive();
ProceedToMainMenu();
}
else
{
_pendingSessionPassword = null;
InvalidateStoredSession();
ShowAuthMessage(string.IsNullOrEmpty(profileErr)
? PlayerConnectionError
@@ -727,6 +748,7 @@ public class LoginManager : MonoBehaviour
}
else
{
_pendingSessionPassword = null;
string serverError = resp != null ? resp.error : null;
if (string.IsNullOrEmpty(serverError) && resp != null && resp.code == AlreadyLoggedInCode)
serverError = "Already logged in on another device";
@@ -739,7 +761,10 @@ public class LoginManager : MonoBehaviour
finally
{
if (!enteredMenu)
{
_pendingSessionPassword = null;
SetBusy(false);
}
}
}
@@ -905,6 +930,8 @@ public class LoginManager : MonoBehaviour
static void ApplyUserData(UserData u)
{
_currentUser = u;
if (u != null)
SupportClient.ClearSessionIfDifferentUser(u.username);
OnUserDataUpdated?.Invoke(u);
}
+546
View File
@@ -0,0 +1,546 @@
using System;
using System.Collections;
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;
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 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);
Debug.Log("[SupportClient] login user='" + body.username +
"' passwordLength=" + body.password.Length);
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>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.</summary>
public static IEnumerator CreateTicket(string category, string body, 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;
}
string json = JsonUtility.ToJson(new CreateTicketRequest { category = category, body = body });
yield return SendAuthorized("/api/support/player/tickets", "POST", json, onComplete, NormalizeThread);
}
/// <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;
Debug.Log("[SupportClient] " + method + " " + url);
using (var request = new UnityWebRequest(url, method))
{
request.downloadHandler = new DownloadHandlerBuffer();
request.timeout = RequestTimeoutSeconds;
if (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
};
}
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;
}
[Serializable]
public class MessageRequest
{
public string body;
}
[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;
}
[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;
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dbf9a007712b0ed92bb464442dd7f8b3