using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using Newtonsoft.Json; using System.Threading; using System.Threading.Tasks; using UnityEngine; namespace GumYum.NPC { /// /// GumYum Game Client for Unity (API Key Authentication Only) /// /// Streamlined client for game integration using API keys only. /// Perfect for embedding in shipped games with just the essential features. /// /// This client does NOT support username/password authentication. /// Use API keys obtained from the GumYum dashboard. /// /// Features: /// - Character spawning and dialogue /// - API key authentication only (no login/register) /// - Automatic JWT token management via API key exchange /// - Automatic retry and error handling /// - Streaming dialogue support /// public class Client : IDisposable { // Events public event EventHandler CharacterSpawned; public event EventHandler DialogueReceived; public event EventHandler DialogueChunkReceived; public event EventHandler DialogueStreamStarted; public event EventHandler DialogueStreamEnded; public event EventHandler RequestCompleted; public event EventHandler RequestFailed; // Configuration public string BaseUrl { get; set; } = "https://npc.gumyum.com"; public string ApiVersion { get; set; } = "v1"; public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(75); // Higher than server's 65s timeout public int MaxRetries { get; set; } = 3; public bool DebugMode { get; set; } = false; // API Credentials private string _apiKey = ""; private string _apiSecret = ""; private string _jwtToken = ""; private string _refreshToken = ""; private DateTime _tokenExpiresAt = DateTime.MinValue; public string ApiKey { get => _apiKey; set { _apiKey = value; if (DebugMode) Debug.Log($"[GumYum Game] API key set: {value}"); } } public string ApiSecret { get => _apiSecret; set { _apiSecret = value; if (DebugMode) Debug.Log("[GumYum Game] API secret set: ****"); } } // HTTP Client private readonly HttpClient _httpClient; private readonly SemaphoreSlim _authSemaphore = new SemaphoreSlim(1, 1); private readonly JsonSerializerSettings _jsonSettings; // Managers public NPCManager NPCs { get; private set; } public UniverseManager Universes { get; private set; } public ChatManager Chat { get; private set; } public Client(HttpClient httpClient = null) { _httpClient = httpClient ?? new HttpClient(); _httpClient.Timeout = Timeout; _jsonSettings = new JsonSerializerSettings { ContractResolver = new Newtonsoft.Json.Serialization .CamelCasePropertyNamesContractResolver(), MissingMemberHandling = MissingMemberHandling.Ignore }; InitializeManagers(); if (DebugMode) Debug.Log("[GumYum Game] Client initialized"); } private void InitializeManagers() { NPCs = new NPCManager(this); Universes = new UniverseManager(this); Chat = new ChatManager(this); } /// /// Check if authenticated /// public bool IsReady() => !string.IsNullOrEmpty(_jwtToken) && DateTime.UtcNow < _tokenExpiresAt; /// /// Check if token needs refresh (within 60 seconds of expiry) /// private bool NeedsRefresh() => !string.IsNullOrEmpty(_jwtToken) && DateTime.UtcNow > _tokenExpiresAt.AddSeconds(-60); /// /// Get full API URL /// private string GetApiUrl(string endpoint) => $"{BaseUrl}/{ApiVersion}/{endpoint.TrimStart('/')}"; /// /// Get request headers /// private void SetRequestHeaders(HttpRequestMessage request) { request.Headers.Clear(); request.Headers.Accept.Add( new MediaTypeWithQualityHeaderValue("application/json")); request.Headers.UserAgent.ParseAdd("GumYum-Unity-SDK/1.0.0"); // Add API key header if we have credentials if (!string.IsNullOrEmpty(_apiKey) && !string.IsNullOrEmpty(_apiSecret)) { request.Headers.Add("X-API-Key", $"{_apiKey}:{_apiSecret}"); } if (!string.IsNullOrEmpty(_jwtToken)) { request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _jwtToken); } } /// /// Make API request /// public async Task RequestAsync(HttpMethod method, string endpoint, object data = null, Dictionary queryParams = null, CancellationToken cancellationToken = default) { // Ensure we're authenticated if (!string.IsNullOrEmpty(_apiKey) && !string.IsNullOrEmpty(_apiSecret) && !IsReady()) { await ExchangeApiKeyForJwtAsync(cancellationToken); } // Check if we need to refresh token if (NeedsRefresh() && !string.IsNullOrEmpty(_refreshToken)) { await RefreshJwtTokenAsync(cancellationToken); } return await ExecuteRequestAsync(method, endpoint, data, queryParams, 0, cancellationToken); } private async Task ExecuteRequestAsync(HttpMethod method, string endpoint, object data, Dictionary queryParams, int retryCount, CancellationToken cancellationToken) { var url = GetApiUrl(endpoint); // Add query parameters if (queryParams?.Count > 0) { var queryString = string.Join( "&", queryParams.Select( kvp => $"{kvp.Key}={Uri.EscapeDataString(kvp.Value)}")); url += "?" + queryString; } using var request = new HttpRequestMessage(method, url); SetRequestHeaders(request); if (data != null && (method == HttpMethod.Post || method == HttpMethod.Put)) { var json = JsonConvert.SerializeObject(data, _jsonSettings); request.Content = new StringContent(json, Encoding.UTF8, "application/json"); } if (DebugMode) { Debug.Log($"[GumYum Game] {method} {endpoint}"); Debug.Log($"[GumYum Game] Full URL: {url}"); } try { var response = await _httpClient.SendAsync(request, cancellationToken); var responseContent = await response.Content.ReadAsStringAsync(); if (DebugMode) { Debug.Log($"[GumYum Game] Response code: {(int)response.StatusCode}"); if (!response.IsSuccessStatusCode) { Debug.Log( $"[GumYum Game] Response body: {responseContent.Substring(0, Math.Min(200, responseContent.Length))}"); } } if (response.IsSuccessStatusCode) { var result = JsonConvert.DeserializeObject(responseContent, _jsonSettings); RequestCompleted?.Invoke( this, new RequestCompletedEventArgs { Endpoint = endpoint, Data = result }); return result; } // Handle errors var errorData = JsonConvert.DeserializeObject>( responseContent, _jsonSettings); var errorMsg = errorData?.GetValueOrDefault("message")?.ToString() ?? errorData?.GetValueOrDefault("error")?.ToString() ?? "Unknown error"; // Handle 403 - try to refresh token only for expired tokens if (response.StatusCode == System.Net.HttpStatusCode.Forbidden && !string.IsNullOrEmpty(_refreshToken) && retryCount == 0) { var errorMessage = errorData?.GetValueOrDefault("error")?.ToString() ?? ""; if (errorMessage.ToLower().Contains("expired") && errorMessage.ToLower().Contains("token")) { if (DebugMode) Debug.Log("[GumYum Game] Got 403 with expired token, attempting " + "token refresh..."); await RefreshJwtTokenAsync(cancellationToken); return await ExecuteRequestAsync(method, endpoint, data, queryParams, retryCount + 1, cancellationToken); } } // For 401 errors (like revoked API keys), don't retry - just fail if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) { if (DebugMode) Debug.Log("[GumYum Game] Got 401 Unauthorized - not retrying"); throw new HttpRequestException( $"Error {(int)response.StatusCode}: {errorMsg}"); } // Retry logic for temporary failures if ((int)response.StatusCode >= 500 && retryCount < MaxRetries) { if (DebugMode) Debug.Log( $"[GumYum Game] Retrying request (attempt {retryCount + 1}/{MaxRetries})"); // Exponential backoff await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, retryCount + 1)), cancellationToken); return await ExecuteRequestAsync(method, endpoint, data, queryParams, retryCount + 1, cancellationToken); } throw new HttpRequestException( $"Error {(int)response.StatusCode}: {errorMsg}"); } catch (Exception ex) { var error = ex.Message; if (DebugMode) Debug.LogError($"[GumYum Game] Request failed: {endpoint} - {error}"); RequestFailed?.Invoke( this, new RequestFailedEventArgs { Endpoint = endpoint, Error = error }); throw; } } private async Task ExchangeApiKeyForJwtAsync(CancellationToken cancellationToken) { if (string.IsNullOrEmpty(_apiKey) || string.IsNullOrEmpty(_apiSecret)) return; await _authSemaphore.WaitAsync(cancellationToken); try { // Double-check after acquiring semaphore if (IsReady()) return; var data = new { public_key = _apiKey, secret_key = _apiSecret }; var url = GetApiUrl("auth/exchange"); using var request = new HttpRequestMessage(HttpMethod.Post, url); SetRequestHeaders(request); var json = JsonConvert.SerializeObject(data, _jsonSettings); request.Content = new StringContent(json, Encoding.UTF8, "application/json"); var response = await _httpClient.SendAsync(request, cancellationToken); var responseContent = await response.Content.ReadAsStringAsync(); if (response.IsSuccessStatusCode) { var responseData = JsonConvert.DeserializeObject>( responseContent, _jsonSettings); _jwtToken = responseData?.GetValueOrDefault("access_token")?.ToString() ?? ""; _refreshToken = responseData?.GetValueOrDefault("refresh_token")?.ToString() ?? ""; var expiresIn = 3600; if (responseData?.TryGetValue("expires_in", out var expiresInObj) == true) { if (expiresInObj is Newtonsoft.Json.Linq.JValue jValue) { expiresIn = jValue.ToObject(); } else { expiresIn = Convert.ToInt32(expiresInObj); } } _tokenExpiresAt = DateTime.UtcNow.AddSeconds(expiresIn); if (DebugMode) { Debug.Log("[GumYum Game] API key exchanged for JWT"); Debug.Log( $"[GumYum Game] Got refresh token: {!string.IsNullOrEmpty(_refreshToken)}"); } } else { var errorMsg = "Invalid API keys"; try { var errorData = JsonConvert.DeserializeObject>( responseContent, _jsonSettings); errorMsg = errorData?.GetValueOrDefault("error")?.ToString() ?? errorData?.GetValueOrDefault("message")?.ToString() ?? errorMsg; } catch { } throw new HttpRequestException($"Authentication failed: {errorMsg}"); } } finally { _authSemaphore.Release(); } } private async Task RefreshJwtTokenAsync(CancellationToken cancellationToken) { if (string.IsNullOrEmpty(_refreshToken)) return; await _authSemaphore.WaitAsync(cancellationToken); try { // Double-check after acquiring semaphore if (!NeedsRefresh()) return; var data = new { refresh_token = _refreshToken }; var url = GetApiUrl("auth/refresh"); using var request = new HttpRequestMessage(HttpMethod.Post, url); SetRequestHeaders(request); var json = JsonConvert.SerializeObject(data, _jsonSettings); request.Content = new StringContent(json, Encoding.UTF8, "application/json"); if (DebugMode) Debug.Log("[GumYum Game] Refreshing JWT token..."); var response = await _httpClient.SendAsync(request, cancellationToken); var responseContent = await response.Content.ReadAsStringAsync(); if (response.IsSuccessStatusCode) { var responseData = JsonConvert.DeserializeObject>( responseContent, _jsonSettings); _jwtToken = responseData?.GetValueOrDefault("access_token")?.ToString() ?? ""; // Server may rotate refresh token if (responseData?.ContainsKey("refresh_token") == true) { _refreshToken = responseData.GetValueOrDefault("refresh_token")?.ToString() ?? ""; } var expiresIn = 3600; if (responseData?.TryGetValue("expires_in", out var expiresInObj) == true) { if (expiresInObj is Newtonsoft.Json.Linq.JValue jValue) { expiresIn = jValue.ToObject(); } else { expiresIn = Convert.ToInt32(expiresInObj); } } _tokenExpiresAt = DateTime.UtcNow.AddSeconds(expiresIn); if (DebugMode) Debug.Log("[GumYum Game] JWT token refreshed successfully"); } else if (response.StatusCode == System.Net.HttpStatusCode.Forbidden) { // Refresh token expired or invalid Debug.LogError( $"[GumYum Game] Refresh token expired or invalid (code 403)"); // Clear tokens _jwtToken = ""; _refreshToken = ""; // If we have API keys, try to re-exchange if (!string.IsNullOrEmpty(_apiKey)) { if (DebugMode) Debug.Log("[GumYum Game] Refresh failed, re-exchanging API key..."); await ExchangeApiKeyForJwtAsync(cancellationToken); } else { throw new HttpRequestException( "Authentication expired - please re-authenticate"); } } else { throw new HttpRequestException( $"Token refresh failed (code {(int)response.StatusCode})"); } } finally { _authSemaphore.Release(); } } // Streaming support for chat completions internal async Task RequestStreamAsync(HttpMethod method, string endpoint, object data, Dictionary queryParams, Action onChunk, CancellationToken cancellationToken) { if (!IsReady()) { throw new InvalidOperationException("Not authenticated"); } var url = GetApiUrl(endpoint); // Add query parameters if (queryParams?.Count > 0) { var queryString = string.Join( "&", queryParams.Select( kvp => $"{kvp.Key}={Uri.EscapeDataString(kvp.Value)}")); url += "?" + queryString; } using var request = new HttpRequestMessage(method, url); SetRequestHeaders(request); request.Headers.Accept.Clear(); request.Headers.Accept.Add( new MediaTypeWithQualityHeaderValue("text/event-stream")); if (data != null) { var json = JsonConvert.SerializeObject(data, _jsonSettings); request.Content = new StringContent(json, Encoding.UTF8, "application/json"); } if (DebugMode) Debug.Log($"[GumYum Stream] Connecting to {url}"); using var response = await _httpClient.SendAsync( request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); response.EnsureSuccessStatusCode(); DialogueStreamStarted?.Invoke(this, EventArgs.Empty); using var stream = await response.Content.ReadAsStreamAsync(); using var reader = new System.IO.StreamReader(stream); var accumulatedContent = ""; var context = new Dictionary(); var moodTransition = new Dictionary(); while (!reader.EndOfStream && !cancellationToken.IsCancellationRequested) { var line = await reader.ReadLineAsync(); if (line == null) break; line = line.Trim(); if (!line.StartsWith("data: ")) continue; var dataStr = line.Substring(6); if (DebugMode) Debug.Log( $"[GumYum Stream] Received data: {dataStr.Substring(0, Math.Min(100, dataStr.Length))}"); if (dataStr == "[DONE]" || dataStr == "done") { // Stream finished if (DebugMode) Debug.Log( $"[GumYum Stream] Stream complete, total content: {accumulatedContent.Length} chars"); DialogueStreamEnded?.Invoke(this, new DialogueStreamEndedEventArgs { FullResponse = accumulatedContent, Context = context, MoodTransition = moodTransition }); onChunk?.Invoke(null); // Signal completion return; } // Parse JSON data try { var eventData = JsonConvert.DeserializeObject>( dataStr, _jsonSettings); // Handle different event types if (eventData.ContainsKey("choices")) { var choices = JsonConvert.DeserializeObject>>( eventData["choices"].ToString(), _jsonSettings); if (choices?.Count > 0) { var choice = choices[0]; if (choice.ContainsKey("delta")) { var delta = JsonConvert.DeserializeObject>( choice["delta"].ToString(), _jsonSettings); if (delta?.ContainsKey("content") == true) { var contentChunk = delta["content"].ToString(); accumulatedContent += contentChunk; DialogueChunkReceived?.Invoke(this, contentChunk); onChunk?.Invoke(contentChunk); } } else if (choice.ContainsKey("message")) { var message = JsonConvert.DeserializeObject>( choice["message"].ToString(), _jsonSettings); if (message?.ContainsKey("content") == true) { var content = message["content"].ToString(); accumulatedContent = content; DialogueChunkReceived?.Invoke(this, content); onChunk?.Invoke(content); } } } } // Check for context and mood transition if (eventData.ContainsKey("npc_context")) { context = JsonConvert.DeserializeObject>( eventData["npc_context"].ToString(), _jsonSettings); } if (eventData.ContainsKey("mood_transition")) { moodTransition = JsonConvert.DeserializeObject>( eventData["mood_transition"].ToString(), _jsonSettings); } } catch (Exception ex) { if (DebugMode) Debug.LogError( $"[GumYum Stream] Error parsing event data: {ex.Message}"); } } // Ensure we emit the final response DialogueStreamEnded?.Invoke(this, new DialogueStreamEndedEventArgs { FullResponse = accumulatedContent, Context = context, MoodTransition = moodTransition }); } // Internal methods for Managers to raise events internal void RaiseCharacterSpawned(NPC npc) { CharacterSpawned?.Invoke(this, npc); } internal void RaiseDialogueReceived(DialogueReceivedEventArgs args) { DialogueReceived?.Invoke(this, args); } internal void RaiseDialogueChunkReceived(string chunk) { DialogueChunkReceived?.Invoke(this, chunk); } internal void RaiseDialogueStreamStarted() { DialogueStreamStarted?.Invoke(this, EventArgs.Empty); } internal void RaiseDialogueStreamEnded(DialogueStreamEndedEventArgs args) { DialogueStreamEnded?.Invoke(this, args); } internal void RaiseRequestCompleted(RequestCompletedEventArgs args) { RequestCompleted?.Invoke(this, args); } internal void RaiseRequestFailed(RequestFailedEventArgs args) { RequestFailed?.Invoke(this, args); } public void Dispose() { _authSemaphore?.Dispose(); _httpClient?.Dispose(); } } // Event Args Classes public class DialogueReceivedEventArgs : EventArgs { public string Response { get; set; } public Dictionary Context { get; set; } public Dictionary MoodTransition { get; set; } } public class DialogueStreamEndedEventArgs : EventArgs { public string FullResponse { get; set; } public Dictionary Context { get; set; } public Dictionary MoodTransition { get; set; } } public class RequestCompletedEventArgs : EventArgs { public string Endpoint { get; set; } public object Data { get; set; } } public class RequestFailedEventArgs : EventArgs { public string Endpoint { get; set; } public string Error { get; set; } } }