mirror of
https://github.com/NotAKidoS/NAK_CVR_Mods.git
synced 2026-02-13 03:36:12 +00:00
ShareBubbles: initial commit
This commit is contained in:
parent
fe768029eb
commit
7b73452df6
39 changed files with 4901 additions and 0 deletions
|
|
@ -0,0 +1,66 @@
|
|||
using System.Net;
|
||||
|
||||
namespace NAK.ShareBubbles.API.Exceptions;
|
||||
|
||||
public class ShareApiException : Exception
|
||||
{
|
||||
public HttpStatusCode StatusCode { get; } // TODO: network back status code to claiming client, to show why the request failed
|
||||
public string UserFriendlyMessage { get; }
|
||||
|
||||
public ShareApiException(HttpStatusCode statusCode, string message, string userFriendlyMessage)
|
||||
: base(message)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
UserFriendlyMessage = userFriendlyMessage;
|
||||
}
|
||||
}
|
||||
|
||||
public class ContentNotSharedException : ShareApiException
|
||||
{
|
||||
public ContentNotSharedException(string contentId)
|
||||
: base(HttpStatusCode.BadRequest,
|
||||
$"Content {contentId} is not currently shared",
|
||||
"This content is not currently shared with anyone")
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public class ContentNotFoundException : ShareApiException
|
||||
{
|
||||
public ContentNotFoundException(string contentId)
|
||||
: base(HttpStatusCode.NotFound,
|
||||
$"Content {contentId} not found",
|
||||
"The specified content could not be found")
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public class UserOnlyAllowsSharesFromFriendsException : ShareApiException
|
||||
{
|
||||
public UserOnlyAllowsSharesFromFriendsException(string userId)
|
||||
: base(HttpStatusCode.Forbidden,
|
||||
$"User {userId} only accepts shares from friends",
|
||||
"This user only accepts shares from friends")
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public class UserNotFoundException : ShareApiException
|
||||
{
|
||||
public UserNotFoundException(string userId)
|
||||
: base(HttpStatusCode.NotFound,
|
||||
$"User {userId} not found",
|
||||
"The specified user could not be found")
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public class ContentAlreadySharedException : ShareApiException
|
||||
{
|
||||
public ContentAlreadySharedException(string contentId, string userId)
|
||||
: base(HttpStatusCode.Conflict,
|
||||
$"Content {contentId} is already shared with user {userId}",
|
||||
"This content is already shared with this user")
|
||||
{
|
||||
}
|
||||
}
|
||||
112
ShareBubbles/ShareBubbles/API/PedestalInfoBatchProcessor.cs
Normal file
112
ShareBubbles/ShareBubbles/API/PedestalInfoBatchProcessor.cs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
using ABI_RC.Core.Networking.API;
|
||||
using NAK.ShareBubbles.API.Responses;
|
||||
|
||||
namespace NAK.ShareBubbles.API;
|
||||
|
||||
public enum PedestalType
|
||||
{
|
||||
Avatar,
|
||||
Prop
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch processor for fetching pedestal info in bulk. The pedestal endpoints are meant to be used in batch, so might
|
||||
/// as well handle bubble requests in batches if in the very unlikely case that multiple requests are made at once.
|
||||
///
|
||||
/// May only really matter for when a late joiner joins a room with a lot of bubbles.
|
||||
///
|
||||
/// Luc: if there is a lot being dropped, you could try to batch them
|
||||
/// </summary>
|
||||
public static class PedestalInfoBatchProcessor
|
||||
{
|
||||
private static readonly Dictionary<PedestalType, Dictionary<string, TaskCompletionSource<PedestalInfoResponseButWithPublicationState>>> _pendingRequests
|
||||
= new()
|
||||
{
|
||||
{ PedestalType.Avatar, new Dictionary<string, TaskCompletionSource<PedestalInfoResponseButWithPublicationState>>() },
|
||||
{ PedestalType.Prop, new Dictionary<string, TaskCompletionSource<PedestalInfoResponseButWithPublicationState>>() }
|
||||
};
|
||||
|
||||
private static readonly Dictionary<PedestalType, bool> _isBatchProcessing
|
||||
= new()
|
||||
{
|
||||
{ PedestalType.Avatar, false },
|
||||
{ PedestalType.Prop, false }
|
||||
};
|
||||
|
||||
private static readonly object _lock = new();
|
||||
private const float BATCH_DELAY = 2f;
|
||||
|
||||
public static Task<PedestalInfoResponseButWithPublicationState> QueuePedestalInfoRequest(PedestalType type, string contentId)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<PedestalInfoResponseButWithPublicationState>();
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
var requests = _pendingRequests[type];
|
||||
|
||||
if (!requests.TryAdd(contentId, tcs))
|
||||
return requests[contentId].Task;
|
||||
|
||||
if (_isBatchProcessing[type])
|
||||
return tcs.Task;
|
||||
|
||||
_isBatchProcessing[type] = true;
|
||||
ProcessBatchAfterDelay(type);
|
||||
}
|
||||
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
private static async void ProcessBatchAfterDelay(PedestalType type)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(BATCH_DELAY));
|
||||
|
||||
List<string> contentIds;
|
||||
Dictionary<string, TaskCompletionSource<PedestalInfoResponseButWithPublicationState>> requestBatch;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
contentIds = _pendingRequests[type].Keys.ToList();
|
||||
requestBatch = new Dictionary<string, TaskCompletionSource<PedestalInfoResponseButWithPublicationState>>(_pendingRequests[type]);
|
||||
_pendingRequests[type].Clear();
|
||||
_isBatchProcessing[type] = false;
|
||||
//ShareBubblesMod.Logger.Msg($"Processing {type} pedestal info batch with {contentIds.Count} items");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ApiConnection.ApiOperation operation = type switch
|
||||
{
|
||||
PedestalType.Avatar => ApiConnection.ApiOperation.AvatarPedestal,
|
||||
PedestalType.Prop => ApiConnection.ApiOperation.PropPedestal,
|
||||
_ => throw new ArgumentException($"Unsupported pedestal type: {type}")
|
||||
};
|
||||
|
||||
var response = await ApiConnection.MakeRequest<IEnumerable<PedestalInfoResponseButWithPublicationState>>(operation, contentIds);
|
||||
|
||||
if (response?.Data != null)
|
||||
{
|
||||
var responseDict = response.Data.ToDictionary(info => info.Id);
|
||||
|
||||
foreach (var kvp in requestBatch)
|
||||
{
|
||||
if (responseDict.TryGetValue(kvp.Key, out var info))
|
||||
kvp.Value.SetResult(info);
|
||||
else
|
||||
kvp.Value.SetException(new Exception($"Content info not found for ID: {kvp.Key}"));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Exception exception = new($"Failed to fetch {type} info batch");
|
||||
foreach (var tcs in requestBatch.Values)
|
||||
tcs.SetException(exception);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
foreach (var tcs in requestBatch.Values)
|
||||
tcs.SetException(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
using Newtonsoft.Json;
|
||||
|
||||
namespace NAK.ShareBubbles.API.Responses;
|
||||
|
||||
public class ActiveSharesResponse
|
||||
{
|
||||
[JsonProperty("value")]
|
||||
public List<ShareUser> Value { get; set; }
|
||||
}
|
||||
|
||||
// Idk why not just reuse UserDetails
|
||||
public class ShareUser
|
||||
{
|
||||
[JsonProperty("image")]
|
||||
public string Image { get; set; }
|
||||
|
||||
[JsonProperty("id")]
|
||||
public string Id { get; set; }
|
||||
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
using ABI_RC.Core.Networking.API.Responses;
|
||||
|
||||
namespace NAK.ShareBubbles.API.Responses;
|
||||
|
||||
/// Same as PedestalInfoResponse, but with an additional field for publication state, if you could not tell by the name.
|
||||
/// TODO: actually waiting on luc to add Published to PedestalInfoResponse
|
||||
[Serializable]
|
||||
public class PedestalInfoResponseButWithPublicationState : UgcResponse
|
||||
{
|
||||
public UserDetails User { get; set; }
|
||||
public bool Permitted { get; set; }
|
||||
public bool Published { get; set; }
|
||||
}
|
||||
236
ShareBubbles/ShareBubbles/API/ShareApiHelper.cs
Normal file
236
ShareBubbles/ShareBubbles/API/ShareApiHelper.cs
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Web;
|
||||
using ABI_RC.Core.Networking;
|
||||
using ABI_RC.Core.Networking.API;
|
||||
using ABI_RC.Core.Networking.API.Responses;
|
||||
using ABI_RC.Core.Savior;
|
||||
using NAK.ShareBubbles.API.Exceptions;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace NAK.ShareBubbles.API;
|
||||
|
||||
/// <summary>
|
||||
/// API for content sharing management.
|
||||
/// </summary>
|
||||
public static class ShareApiHelper
|
||||
{
|
||||
#region Enums
|
||||
|
||||
public enum ShareContentType
|
||||
{
|
||||
Avatar,
|
||||
Spawnable
|
||||
}
|
||||
|
||||
private enum ShareApiOperation
|
||||
{
|
||||
ShareAvatar,
|
||||
ReleaseAvatar,
|
||||
|
||||
ShareSpawnable,
|
||||
ReleaseSpawnable,
|
||||
|
||||
GetAvatarShares,
|
||||
GetSpawnableShares
|
||||
}
|
||||
|
||||
#endregion Enums
|
||||
|
||||
#region Public API
|
||||
|
||||
/// <summary>
|
||||
/// Shares content with a specified user.
|
||||
/// </summary>
|
||||
/// <param name="type">Type of content to share</param>
|
||||
/// <param name="contentId">ID of the content</param>
|
||||
/// <param name="userId">Target user ID</param>
|
||||
/// <returns>Response containing share information</returns>
|
||||
/// <exception cref="ShareApiException">Thrown when API request fails</exception>
|
||||
/// <exception cref="UserNotFoundException">Thrown when target user is not found</exception>
|
||||
/// <exception cref="ContentNotFoundException">Thrown when content is not found</exception>
|
||||
/// <exception cref="UserOnlyAllowsSharesFromFriendsException">Thrown when user only accepts shares from friends</exception>
|
||||
/// <exception cref="ContentAlreadySharedException">Thrown when content is already shared with user</exception>
|
||||
public static Task<BaseResponse<T>> ShareContentAsync<T>(ShareContentType type, string contentId, string userId)
|
||||
{
|
||||
ShareApiOperation operation = type == ShareContentType.Avatar
|
||||
? ShareApiOperation.ShareAvatar
|
||||
: ShareApiOperation.ShareSpawnable;
|
||||
|
||||
ShareRequest data = new()
|
||||
{
|
||||
ContentId = contentId,
|
||||
UserId = userId
|
||||
};
|
||||
|
||||
return MakeApiRequestAsync<T>(operation, data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases shared content from a specified user.
|
||||
/// </summary>
|
||||
/// <param name="type">Type of content to release</param>
|
||||
/// <param name="contentId">ID of the content</param>
|
||||
/// <param name="userId">Optional user ID. If null, releases share from self</param>
|
||||
/// <returns>Response indicating success</returns>
|
||||
/// <exception cref="ShareApiException">Thrown when API request fails</exception>
|
||||
/// <exception cref="ContentNotSharedException">Thrown when content is not shared</exception>
|
||||
/// <exception cref="ContentNotFoundException">Thrown when content is not found</exception>
|
||||
/// <exception cref="UserNotFoundException">Thrown when specified user is not found</exception>
|
||||
public static Task<BaseResponse<T>> ReleaseShareAsync<T>(ShareContentType type, string contentId, string userId = null)
|
||||
{
|
||||
ShareApiOperation operation = type == ShareContentType.Avatar
|
||||
? ShareApiOperation.ReleaseAvatar
|
||||
: ShareApiOperation.ReleaseSpawnable;
|
||||
|
||||
// If no user ID is provided, release share from self
|
||||
userId ??= MetaPort.Instance.ownerId;
|
||||
|
||||
ShareRequest data = new()
|
||||
{
|
||||
ContentId = contentId,
|
||||
UserId = userId
|
||||
};
|
||||
|
||||
return MakeApiRequestAsync<T>(operation, data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all shares for specified content.
|
||||
/// </summary>
|
||||
/// <param name="type">Type of content</param>
|
||||
/// <param name="contentId">ID of the content</param>
|
||||
/// <returns>Response containing share information</returns>
|
||||
/// <exception cref="ShareApiException">Thrown when API request fails</exception>
|
||||
/// <exception cref="ContentNotFoundException">Thrown when content is not found</exception>
|
||||
public static Task<BaseResponse<T>> GetSharesAsync<T>(ShareContentType type, string contentId)
|
||||
{
|
||||
ShareApiOperation operation = type == ShareContentType.Avatar
|
||||
? ShareApiOperation.GetAvatarShares
|
||||
: ShareApiOperation.GetSpawnableShares;
|
||||
|
||||
ShareRequest data = new() { ContentId = contentId };
|
||||
return MakeApiRequestAsync<T>(operation, data);
|
||||
}
|
||||
|
||||
#endregion Public API
|
||||
|
||||
#region Private Implementation
|
||||
|
||||
[Serializable]
|
||||
private record ShareRequest
|
||||
{
|
||||
public string ContentId { get; set; }
|
||||
public string UserId { get; set; }
|
||||
}
|
||||
|
||||
private static async Task<BaseResponse<T>> MakeApiRequestAsync<T>(ShareApiOperation operation, ShareRequest data)
|
||||
{
|
||||
ValidateAuthenticationState();
|
||||
|
||||
(string endpoint, HttpMethod method) = GetApiEndpointAndMethod(operation, data);
|
||||
using HttpRequestMessage request = CreateHttpRequest(endpoint, method, data);
|
||||
|
||||
try
|
||||
{
|
||||
using HttpResponseMessage response = await ApiConnection._client.SendAsync(request);
|
||||
string content = await response.Content.ReadAsStringAsync();
|
||||
|
||||
return HandleApiResponse<T>(response, content, data.ContentId, data.UserId);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
throw new ShareApiException(
|
||||
HttpStatusCode.ServiceUnavailable,
|
||||
$"Failed to communicate with the server: {ex.Message}",
|
||||
"Unable to connect to the server. Please check your internet connection.");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new ShareApiException(
|
||||
HttpStatusCode.UnprocessableEntity,
|
||||
$"Failed to process response data: {ex.Message}",
|
||||
"Server returned invalid data. Please try again later.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateAuthenticationState()
|
||||
{
|
||||
if (!AuthManager.IsAuthenticated)
|
||||
{
|
||||
throw new ShareApiException(
|
||||
HttpStatusCode.Unauthorized,
|
||||
"User is not authenticated",
|
||||
"Please log in to perform this action");
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpRequestMessage CreateHttpRequest(string endpoint, HttpMethod method, ShareRequest data)
|
||||
{
|
||||
HttpRequestMessage request = new(method, endpoint);
|
||||
|
||||
if (method == HttpMethod.Post)
|
||||
{
|
||||
JObject json = JObject.FromObject(data);
|
||||
request.Content = new StringContent(json.ToString(), Encoding.UTF8, "application/json");
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
private static BaseResponse<T> HandleApiResponse<T>(HttpResponseMessage response, string content, string contentId, string userId)
|
||||
{
|
||||
if (response.IsSuccessStatusCode)
|
||||
return CreateSuccessResponse<T>(content);
|
||||
|
||||
// Let specific exceptions propagate up to the caller
|
||||
throw response.StatusCode switch
|
||||
{
|
||||
HttpStatusCode.BadRequest => new ContentNotSharedException(contentId),
|
||||
HttpStatusCode.NotFound when userId != null => new UserNotFoundException(userId),
|
||||
HttpStatusCode.NotFound => new ContentNotFoundException(contentId),
|
||||
HttpStatusCode.Forbidden => new UserOnlyAllowsSharesFromFriendsException(userId),
|
||||
HttpStatusCode.Conflict => new ContentAlreadySharedException(contentId, userId),
|
||||
_ => new ShareApiException(
|
||||
response.StatusCode,
|
||||
$"API request failed with status {response.StatusCode}: {content}",
|
||||
"An unexpected error occurred. Please try again later.")
|
||||
};
|
||||
}
|
||||
|
||||
private static BaseResponse<T> CreateSuccessResponse<T>(string content)
|
||||
{
|
||||
var response = new BaseResponse<T>("")
|
||||
{
|
||||
IsSuccessStatusCode = true,
|
||||
HttpStatusCode = HttpStatusCode.OK
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(content))
|
||||
{
|
||||
response.Data = JsonConvert.DeserializeObject<T>(content);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private static (string endpoint, HttpMethod method) GetApiEndpointAndMethod(ShareApiOperation operation, ShareRequest data)
|
||||
{
|
||||
string baseUrl = $"{ApiConnection.APIAddress}/{ApiConnection.APIVersion}";
|
||||
string encodedContentId = HttpUtility.UrlEncode(data.ContentId);
|
||||
|
||||
return operation switch
|
||||
{
|
||||
ShareApiOperation.GetAvatarShares => ($"{baseUrl}/avatars/{encodedContentId}/shares", HttpMethod.Get),
|
||||
ShareApiOperation.ShareAvatar => ($"{baseUrl}/avatars/{encodedContentId}/shares/{HttpUtility.UrlEncode(data.UserId)}", HttpMethod.Post),
|
||||
ShareApiOperation.ReleaseAvatar => ($"{baseUrl}/avatars/{encodedContentId}/shares/{HttpUtility.UrlEncode(data.UserId)}", HttpMethod.Delete),
|
||||
ShareApiOperation.GetSpawnableShares => ($"{baseUrl}/spawnables/{encodedContentId}/shares", HttpMethod.Get),
|
||||
ShareApiOperation.ShareSpawnable => ($"{baseUrl}/spawnables/{encodedContentId}/shares/{HttpUtility.UrlEncode(data.UserId)}", HttpMethod.Post),
|
||||
ShareApiOperation.ReleaseSpawnable => ($"{baseUrl}/spawnables/{encodedContentId}/shares/{HttpUtility.UrlEncode(data.UserId)}", HttpMethod.Delete),
|
||||
_ => throw new ArgumentException($"Unknown operation: {operation}")
|
||||
};
|
||||
}
|
||||
|
||||
#endregion Private Implementation
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue