npc-clients-unity/Runtime/Client.cs

620 lines
No EOL
21 KiB
C#

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 {
/// <summary>
/// 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
/// </summary>
public class Client : IDisposable {
// Events
public event EventHandler<NPC> CharacterSpawned;
public event EventHandler<DialogueReceivedEventArgs> DialogueReceived;
public event EventHandler<string> DialogueChunkReceived;
public event EventHandler DialogueStreamStarted;
public event EventHandler<DialogueStreamEndedEventArgs> DialogueStreamEnded;
public event EventHandler<RequestCompletedEventArgs> RequestCompleted;
public event EventHandler<RequestFailedEventArgs> 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);
}
/// <summary>
/// Check if authenticated
/// </summary>
public bool IsReady() =>
!string.IsNullOrEmpty(_jwtToken) && DateTime.UtcNow < _tokenExpiresAt;
/// <summary>
/// Check if token needs refresh (within 60 seconds of expiry)
/// </summary>
private bool NeedsRefresh() =>
!string.IsNullOrEmpty(_jwtToken) && DateTime.UtcNow
> _tokenExpiresAt.AddSeconds(-60);
/// <summary>
/// Get full API URL
/// </summary>
private string GetApiUrl(string endpoint) =>
$"{BaseUrl}/{ApiVersion}/{endpoint.TrimStart('/')}";
/// <summary>
/// Get request headers
/// </summary>
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);
}
}
/// <summary>
/// Make API request
/// </summary>
public async Task<T>
RequestAsync<T>(HttpMethod method, string endpoint, object data = null,
Dictionary<string, string> 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<T>(method, endpoint, data, queryParams, 0,
cancellationToken);
}
private async Task<T>
ExecuteRequestAsync<T>(HttpMethod method, string endpoint, object data,
Dictionary<string, string> 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<T>(responseContent, _jsonSettings);
RequestCompleted?.Invoke(
this, new RequestCompletedEventArgs { Endpoint = endpoint,
Data = result });
return result;
}
// Handle errors
var errorData = JsonConvert.DeserializeObject<Dictionary<string, object>>(
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<T>(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<T>(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<Dictionary<string, object>>(
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<int>();
} 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<Dictionary<string, object>>(
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<Dictionary<string, object>>(
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<int>();
} 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<string, string> queryParams,
Action<string> 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<string, object>();
var moodTransition = new Dictionary<string, object>();
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<Dictionary<string, object>>(
dataStr, _jsonSettings);
// Handle different event types
if (eventData.ContainsKey("choices")) {
var choices =
JsonConvert.DeserializeObject<List<Dictionary<string, object>>>(
eventData["choices"].ToString(), _jsonSettings);
if (choices?.Count > 0) {
var choice = choices[0];
if (choice.ContainsKey("delta")) {
var delta =
JsonConvert.DeserializeObject<Dictionary<string, object>>(
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<Dictionary<string, object>>(
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<Dictionary<string, object>>(
eventData["npc_context"].ToString(), _jsonSettings);
}
if (eventData.ContainsKey("mood_transition")) {
moodTransition =
JsonConvert.DeserializeObject<Dictionary<string, object>>(
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<string, object> Context { get; set; }
public Dictionary<string, object> MoodTransition { get; set; }
}
public class DialogueStreamEndedEventArgs : EventArgs {
public string FullResponse { get; set; }
public Dictionary<string, object> Context { get; set; }
public Dictionary<string, object> 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; }
}
}