ShareBubbles: initial commit

This commit is contained in:
NotAKidoS 2024-12-02 20:06:26 -06:00
parent fe768029eb
commit 7b73452df6
39 changed files with 4901 additions and 0 deletions

View file

@ -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")
{
}
}

View 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);
}
}
}

View file

@ -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; }
}

View file

@ -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; }
}

View 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
}