Compare commits
	
		
			2 Commits
		
	
	
		
			2a3918134f
			...
			bec294365f
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| bec294365f | |||
| 7b026eeae1 | 
							
								
								
									
										82
									
								
								DysonNetwork.Pass/Team/Team.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								DysonNetwork.Pass/Team/Team.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using System.ComponentModel.DataAnnotations.Schema; | ||||
| using System.Text.Json.Serialization; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Pass.Team; | ||||
|  | ||||
| public enum TeamType | ||||
| { | ||||
|     Individual, | ||||
|     Organizational | ||||
| } | ||||
|  | ||||
| [Index(nameof(Name), IsUnique = true)] | ||||
| public class Team : ModelBase, IIdentifiedResource | ||||
| { | ||||
|     public Guid Id { get; set; } | ||||
|     public TeamType Type { get; set; } | ||||
|     [MaxLength(256)] public string Name { get; set; } = string.Empty; | ||||
|     [MaxLength(256)] public string Nick { get; set; } = string.Empty; | ||||
|     [MaxLength(4096)] public string? Bio { get; set; } | ||||
|  | ||||
|     // Outdated fields, for backward compability | ||||
|     [MaxLength(32)] public string? PictureId { get; set; } | ||||
|     [MaxLength(32)] public string? BackgroundId { get; set; } | ||||
|  | ||||
|     [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; } | ||||
|     [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; } | ||||
|  | ||||
|     [Column(TypeName = "jsonb")] public Account.VerificationMark? Verification { get; set; } | ||||
|  | ||||
|     [JsonIgnore] public ICollection<TeamMember> Members { get; set; } = new List<TeamMember>(); | ||||
|     [JsonIgnore] public ICollection<TeamFeature> Features { get; set; } = new List<TeamFeature>(); | ||||
|  | ||||
|     public Guid? AccountId { get; set; } | ||||
|     public Account.Account? Account { get; set; } | ||||
|  | ||||
|     public string ResourceIdentifier => $"publisher/{Id}"; | ||||
| } | ||||
|  | ||||
| public enum TeamMemberRole | ||||
| { | ||||
|     Owner = 100, | ||||
|     Manager = 75, | ||||
|     Editor = 50, | ||||
|     Viewer = 25 | ||||
| } | ||||
|  | ||||
| public class TeamMember : ModelBase | ||||
| { | ||||
|     public Guid TeamId { get; set; } | ||||
|     [JsonIgnore] public Team Team { get; set; } = null!; | ||||
|     public Guid AccountId { get; set; } | ||||
|     public Account.Account Account { get; set; } = null!; | ||||
|  | ||||
|     public TeamMemberRole Role { get; set; } = TeamMemberRole.Viewer; | ||||
|     public Instant? JoinedAt { get; set; } | ||||
| } | ||||
|  | ||||
| public enum TeamSubscriptionStatus | ||||
| { | ||||
|     Active, | ||||
|     Expired, | ||||
|     Cancelled | ||||
| } | ||||
|  | ||||
| public class TeamFeature : ModelBase | ||||
| { | ||||
|     public Guid Id { get; set; } | ||||
|     [MaxLength(1024)] public string Flag { get; set; } = null!; | ||||
|     public Instant? ExpiredAt { get; set; } | ||||
|  | ||||
|     public Guid TeamId { get; set; } | ||||
|     public Team Team { get; set; } = null!; | ||||
| } | ||||
|  | ||||
| public abstract class TeamFeatureFlag | ||||
| { | ||||
|     public static List<string> AllFlags => [Develop]; | ||||
|     public static string Develop = "develop"; | ||||
| } | ||||
							
								
								
									
										305
									
								
								DysonNetwork.Sphere/Chat/Realtime/CloudflareService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										305
									
								
								DysonNetwork.Sphere/Chat/Realtime/CloudflareService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,305 @@ | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
| using System.Text.Json; | ||||
| using Microsoft.IdentityModel.Tokens; | ||||
| using System.Text.Json.Serialization; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Chat.Realtime; | ||||
|  | ||||
| public class CloudflareRealtimeService : IRealtimeService | ||||
| { | ||||
|     private readonly AppDatabase _db; | ||||
|     private readonly HttpClient _httpClient; | ||||
|     private readonly IConfiguration _configuration; | ||||
|     private RSA? _publicKey; | ||||
|  | ||||
|     public CloudflareRealtimeService( | ||||
|         AppDatabase db, | ||||
|         HttpClient httpClient, | ||||
|         IConfiguration configuration | ||||
|     ) | ||||
|     { | ||||
|         _db = db; | ||||
|         _httpClient = httpClient; | ||||
|         _configuration = configuration; | ||||
|         var apiKey = _configuration["Realtime:Cloudflare:ApiKey"]; | ||||
|         var apiSecret = _configuration["Realtime:Cloudflare:ApiSecret"]!; | ||||
|         var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{apiKey}:{apiSecret}")); | ||||
|         _httpClient.BaseAddress = new Uri("https://rtk.realtime.cloudflare.com/v2/"); | ||||
|         _httpClient.DefaultRequestHeaders.Authorization = | ||||
|             new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", credentials); | ||||
|     } | ||||
|  | ||||
|     public string ProviderName => "Cloudflare"; | ||||
|  | ||||
|     public async Task<RealtimeSessionConfig> CreateSessionAsync(Guid roomId, Dictionary<string, object> metadata) | ||||
|     { | ||||
|         var roomName = $"Room Call #{roomId.ToString().Replace("-", "")}"; | ||||
|         var requestBody = new | ||||
|         { | ||||
|             title = roomName, | ||||
|             preferred_region = _configuration["Realtime:Cloudflare:PreferredRegion"], | ||||
|             data = metadata | ||||
|         }; | ||||
|  | ||||
|         var content = new StringContent(JsonSerializer.Serialize(requestBody), Encoding.UTF8, "application/json"); | ||||
|         var response = await _httpClient.PostAsync("meetings", content); | ||||
|  | ||||
|         response.EnsureSuccessStatusCode(); | ||||
|  | ||||
|         var responseContent = await response.Content.ReadAsStringAsync(); | ||||
|         var meetingResponse = JsonSerializer.Deserialize<CfMeetingResponse>(responseContent); | ||||
|         if (meetingResponse is null) throw new Exception("Failed to create meeting with cloudflare"); | ||||
|  | ||||
|         return new RealtimeSessionConfig | ||||
|         { | ||||
|             SessionId = meetingResponse.Data.Id, | ||||
|             Parameters = new Dictionary<string, object> | ||||
|             { | ||||
|                 { "meetingId", meetingResponse.Data.Id } | ||||
|             } | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     public async Task EndSessionAsync(string sessionId, RealtimeSessionConfig config) | ||||
|     { | ||||
|         var requestBody = new | ||||
|         { | ||||
|             status = "INACTIVE" | ||||
|         }; | ||||
|  | ||||
|         var content = new StringContent(JsonSerializer.Serialize(requestBody), Encoding.UTF8, "application/json"); | ||||
|         var response = await _httpClient.PatchAsync($"sessions/{sessionId}", content); | ||||
|  | ||||
|         response.EnsureSuccessStatusCode(); | ||||
|     } | ||||
|  | ||||
|     public string GetUserToken(Account.Account account, string sessionId, bool isAdmin = false) | ||||
|     { | ||||
|         return GetUserTokenAsync(account, sessionId, isAdmin).GetAwaiter().GetResult(); | ||||
|     } | ||||
|  | ||||
|     public async Task<string> GetUserTokenAsync(Account.Account account, string sessionId, bool isAdmin = false) | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             // First try to get the participant by their custom ID | ||||
|             var participantCheckResponse = await _httpClient | ||||
|                 .GetAsync($"meetings/{sessionId}/participants/{account.Id}"); | ||||
|  | ||||
|             if (participantCheckResponse.IsSuccessStatusCode) | ||||
|             { | ||||
|                 // Participant exists, get a new token | ||||
|                 var tokenResponse = await _httpClient | ||||
|                     .PostAsync($"meetings/{sessionId}/participants/{account.Id}/token", null); | ||||
|                 tokenResponse.EnsureSuccessStatusCode(); | ||||
|                 var tokenContent = await tokenResponse.Content.ReadAsStringAsync(); | ||||
|                 var tokenData = JsonSerializer.Deserialize<CfResponse<CfTokenResponse>>(tokenContent); | ||||
|                 if (tokenData == null || !tokenData.Success) | ||||
|                 { | ||||
|                     throw new Exception("Failed to get participant token"); | ||||
|                 } | ||||
|  | ||||
|                 return tokenData.Data?.Token ?? throw new Exception("Token is null"); | ||||
|             } | ||||
|  | ||||
|             // Participant doesn't exist, create a new one | ||||
|             var requestBody = new | ||||
|             { | ||||
|                 name = "@" + account.Name, | ||||
|                 preset_name = isAdmin ? "group_call_host" : "group_call_participant", | ||||
|                 custom_user_id = account.Id.ToString() | ||||
|             }; | ||||
|  | ||||
|             var content = new StringContent( | ||||
|                 JsonSerializer.Serialize(requestBody), | ||||
|                 Encoding.UTF8, | ||||
|                 "application/json" | ||||
|             ); | ||||
|  | ||||
|             var createResponse = await _httpClient.PostAsync($"meetings/{sessionId}/participants", content); | ||||
|             createResponse.EnsureSuccessStatusCode(); | ||||
|  | ||||
|             var responseContent = await createResponse.Content.ReadAsStringAsync(); | ||||
|             var participantData = JsonSerializer.Deserialize<CfResponse<CfParticipantResponse>>(responseContent); | ||||
|             if (participantData == null || !participantData.Success) | ||||
|             { | ||||
|                 throw new Exception("Failed to create participant"); | ||||
|             } | ||||
|  | ||||
|             return participantData.Data?.Token ?? throw new Exception("Token is null"); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             // Log the error or handle it appropriately | ||||
|             throw new Exception($"Failed to get or create participant: {ex.Message}", ex); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public async Task ReceiveWebhook(string body, string authHeader) | ||||
|     { | ||||
|         if (string.IsNullOrEmpty(authHeader)) | ||||
|         { | ||||
|             throw new ArgumentException("Auth header is missing"); | ||||
|         } | ||||
|  | ||||
|         if (_publicKey == null) | ||||
|         { | ||||
|             await GetPublicKeyAsync(); | ||||
|         } | ||||
|  | ||||
|         var signature = authHeader.Replace("Signature ", ""); | ||||
|         var bodyBytes = Encoding.UTF8.GetBytes(body); | ||||
|         var signatureBytes = Convert.FromBase64String(signature); | ||||
|  | ||||
|         if (!(_publicKey?.VerifyData(bodyBytes, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1) ?? | ||||
|               false)) | ||||
|         { | ||||
|             throw new SecurityTokenException("Webhook signature validation failed"); | ||||
|         } | ||||
|  | ||||
|         // Process the webhook event | ||||
|         var evt = JsonSerializer.Deserialize<CfWebhookEvent>(body); | ||||
|         if (evt is null) return; | ||||
|  | ||||
|         switch (evt.Type) | ||||
|         { | ||||
|             case "meeting.ended": | ||||
|                 var now = SystemClock.Instance.GetCurrentInstant(); | ||||
|                 await _db.ChatRealtimeCall | ||||
|                     .Where(c => c.SessionId == evt.Event.Meeting.Id) | ||||
|                     .ExecuteUpdateAsync(s => s.SetProperty(p => p.EndedAt, now) | ||||
|                     ); | ||||
|                 break; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private class WebhooksConfig | ||||
|     { | ||||
|         [JsonPropertyName("keys")] public List<WebhookKey> Keys { get; set; } = new List<WebhookKey>(); | ||||
|     } | ||||
|  | ||||
|     private class WebhookKey | ||||
|     { | ||||
|         [JsonPropertyName("publicKeyPem")] public string PublicKeyPem { get; set; } = string.Empty; | ||||
|     } | ||||
|  | ||||
|     private async Task GetPublicKeyAsync() | ||||
|     { | ||||
|         var response = await _httpClient.GetAsync("https://rtk.realtime.cloudflare.com/.well-known/webhooks.json"); | ||||
|         response.EnsureSuccessStatusCode(); | ||||
|         var content = await response.Content.ReadAsStringAsync(); | ||||
|         var webhooksConfig = JsonSerializer.Deserialize<WebhooksConfig>(content); | ||||
|         var publicKeyPem = webhooksConfig?.Keys.FirstOrDefault()?.PublicKeyPem; | ||||
|  | ||||
|         if (string.IsNullOrEmpty(publicKeyPem)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Public key not found in webhooks configuration."); | ||||
|         } | ||||
|  | ||||
|         _publicKey = RSA.Create(); | ||||
|         _publicKey.ImportFromPem(publicKeyPem); | ||||
|     } | ||||
|  | ||||
|     private class CfMeetingResponse | ||||
|     { | ||||
|         [JsonPropertyName("data")] public CfMeetingData Data { get; set; } = new(); | ||||
|     } | ||||
|  | ||||
|     private class CfMeetingData | ||||
|     { | ||||
|         [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; | ||||
|         [JsonPropertyName("roomName")] public string RoomName { get; set; } = string.Empty; | ||||
|         [JsonPropertyName("title")] public string Title { get; set; } = string.Empty; | ||||
|         [JsonPropertyName("status")] public string Status { get; set; } = string.Empty; | ||||
|         [JsonPropertyName("createdAt")] public DateTime CreatedAt { get; set; } | ||||
|         [JsonPropertyName("updatedAt")] public DateTime UpdatedAt { get; set; } | ||||
|     } | ||||
|  | ||||
|     private class CfParticipant | ||||
|     { | ||||
|         public string Id { get; set; } = string.Empty; | ||||
|         public string Token { get; set; } = string.Empty; | ||||
|         public string CustomParticipantId { get; set; } = string.Empty; | ||||
|     } | ||||
|  | ||||
|     public class CfResponse<T> | ||||
|     { | ||||
|         [JsonPropertyName("success")] public bool Success { get; set; } | ||||
|  | ||||
|         [JsonPropertyName("data")] public T? Data { get; set; } | ||||
|     } | ||||
|  | ||||
|     public class CfTokenResponse | ||||
|     { | ||||
|         [JsonPropertyName("token")] public string Token { get; set; } = string.Empty; | ||||
|     } | ||||
|  | ||||
|     public class CfParticipantResponse | ||||
|     { | ||||
|         [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; | ||||
|  | ||||
|         [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; | ||||
|  | ||||
|         [JsonPropertyName("customUserId")] public string CustomUserId { get; set; } = string.Empty; | ||||
|  | ||||
|         [JsonPropertyName("presetName")] public string PresetName { get; set; } = string.Empty; | ||||
|  | ||||
|         [JsonPropertyName("isActive")] public bool IsActive { get; set; } | ||||
|  | ||||
|         [JsonPropertyName("token")] public string Token { get; set; } = string.Empty; | ||||
|     } | ||||
|  | ||||
|     public class CfWebhookEvent | ||||
|     { | ||||
|         [JsonPropertyName("id")] public string Id { get; set; } | ||||
|  | ||||
|         [JsonPropertyName("type")] public string Type { get; set; } | ||||
|  | ||||
|         [JsonPropertyName("webhookId")] public string WebhookId { get; set; } | ||||
|  | ||||
|         [JsonPropertyName("timestamp")] public DateTime Timestamp { get; set; } | ||||
|  | ||||
|         [JsonPropertyName("event")] public EventData Event { get; set; } | ||||
|     } | ||||
|  | ||||
|     public class EventData | ||||
|     { | ||||
|         [JsonPropertyName("meeting")] public MeetingData Meeting { get; set; } | ||||
|  | ||||
|         [JsonPropertyName("participant")] public ParticipantData Participant { get; set; } | ||||
|     } | ||||
|  | ||||
|     public class MeetingData | ||||
|     { | ||||
|         [JsonPropertyName("id")] public string Id { get; set; } | ||||
|  | ||||
|         [JsonPropertyName("roomName")] public string RoomName { get; set; } | ||||
|  | ||||
|         [JsonPropertyName("title")] public string Title { get; set; } | ||||
|  | ||||
|         [JsonPropertyName("status")] public string Status { get; set; } | ||||
|  | ||||
|         [JsonPropertyName("createdAt")] public DateTime CreatedAt { get; set; } | ||||
|  | ||||
|         [JsonPropertyName("updatedAt")] public DateTime UpdatedAt { get; set; } | ||||
|     } | ||||
|  | ||||
|     public class ParticipantData | ||||
|     { | ||||
|         [JsonPropertyName("id")] public string Id { get; set; } | ||||
|  | ||||
|         [JsonPropertyName("userId")] public string UserId { get; set; } | ||||
|  | ||||
|         [JsonPropertyName("customParticipantId")] | ||||
|         public string CustomParticipantId { get; set; } | ||||
|  | ||||
|         [JsonPropertyName("presetName")] public string PresetName { get; set; } | ||||
|  | ||||
|         [JsonPropertyName("joinedAt")] public DateTime JoinedAt { get; set; } | ||||
|  | ||||
|         [JsonPropertyName("leftAt")] public DateTime? LeftAt { get; set; } | ||||
|     } | ||||
| } | ||||
| @@ -30,7 +30,7 @@ public interface IRealtimeService | ||||
|     Task EndSessionAsync(string sessionId, RealtimeSessionConfig config); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Gets a token for user to join the session | ||||
|     /// Gets a token for user to join the session (synchronous version for backward compatibility) | ||||
|     /// </summary> | ||||
|     /// <param name="account">The user identifier</param> | ||||
|     /// <param name="sessionId">The session identifier</param> | ||||
| @@ -38,6 +38,15 @@ public interface IRealtimeService | ||||
|     /// <returns>User-specific token for the session</returns> | ||||
|     string GetUserToken(Account.Account account, string sessionId, bool isAdmin = false); | ||||
|      | ||||
|     /// <summary> | ||||
|     /// Gets a token for user to join the session asynchronously | ||||
|     /// </summary> | ||||
|     /// <param name="account">The user identifier</param> | ||||
|     /// <param name="sessionId">The session identifier</param> | ||||
|     /// <param name="isAdmin">The user is the admin of session</param> | ||||
|     /// <returns>Task that resolves to the user-specific token for the session</returns> | ||||
|     Task<string> GetUserTokenAsync(Account.Account account, string sessionId, bool isAdmin = false); | ||||
|      | ||||
|     /// <summary> | ||||
|     /// Processes incoming webhook requests from the realtime service provider | ||||
|     /// </summary> | ||||
|   | ||||
| @@ -10,33 +10,33 @@ namespace DysonNetwork.Sphere.Chat.Realtime; | ||||
| /// <summary> | ||||
| /// LiveKit implementation of the real-time communication service | ||||
| /// </summary> | ||||
| public class LivekitRealtimeService : IRealtimeService | ||||
| public class LiveKitRealtimeService : IRealtimeService | ||||
| { | ||||
|     private readonly AppDatabase _db; | ||||
|     private readonly ICacheService _cache; | ||||
|     private readonly WebSocketService _ws; | ||||
|     private readonly RealtimeStatusService _callStatus; | ||||
| 
 | ||||
|     private readonly ILogger<LivekitRealtimeService> _logger; | ||||
|     private readonly ILogger<LiveKitRealtimeService> _logger; | ||||
|     private readonly RoomServiceClient _roomService; | ||||
|     private readonly AccessToken _accessToken; | ||||
|     private readonly WebhookReceiver _webhookReceiver; | ||||
| 
 | ||||
|     public LivekitRealtimeService( | ||||
|     public LiveKitRealtimeService( | ||||
|         IConfiguration configuration, | ||||
|         ILogger<LivekitRealtimeService> logger, | ||||
|         ILogger<LiveKitRealtimeService> logger, | ||||
|         AppDatabase db, | ||||
|         ICacheService cache, | ||||
|         WebSocketService ws | ||||
|         RealtimeStatusService callStatus | ||||
|     ) | ||||
|     { | ||||
|         _logger = logger; | ||||
| 
 | ||||
|         // Get LiveKit configuration from appsettings | ||||
|         var host = configuration["RealtimeChat:Endpoint"] ?? | ||||
|         var host = configuration["Realtime:LiveKit:Endpoint"] ?? | ||||
|                    throw new ArgumentNullException("Endpoint configuration is required"); | ||||
|         var apiKey = configuration["RealtimeChat:ApiKey"] ?? | ||||
|         var apiKey = configuration["Realtime:LiveKit:ApiKey"] ?? | ||||
|                      throw new ArgumentNullException("ApiKey configuration is required"); | ||||
|         var apiSecret = configuration["RealtimeChat:ApiSecret"] ?? | ||||
|         var apiSecret = configuration["Realtime:LiveKit:ApiSecret"] ?? | ||||
|                         throw new ArgumentNullException("ApiSecret configuration is required"); | ||||
| 
 | ||||
|         _roomService = new RoomServiceClient(host, apiKey, apiSecret); | ||||
| @@ -45,7 +45,7 @@ public class LivekitRealtimeService : IRealtimeService | ||||
| 
 | ||||
|         _db = db; | ||||
|         _cache = cache; | ||||
|         _ws = ws; | ||||
|         _callStatus = callStatus; | ||||
|     } | ||||
| 
 | ||||
|     /// <inheritdoc /> | ||||
| @@ -112,6 +112,11 @@ public class LivekitRealtimeService : IRealtimeService | ||||
| 
 | ||||
|     /// <inheritdoc /> | ||||
|     public string GetUserToken(Account.Account account, string sessionId, bool isAdmin = false) | ||||
|     { | ||||
|         return GetUserTokenAsync(account, sessionId, isAdmin).GetAwaiter().GetResult(); | ||||
|     } | ||||
| 
 | ||||
|     public Task<string> GetUserTokenAsync(Account.Account account, string sessionId, bool isAdmin = false) | ||||
|     { | ||||
|         var token = _accessToken.WithIdentity(account.Name) | ||||
|             .WithName(account.Nick) | ||||
| @@ -128,7 +133,7 @@ public class LivekitRealtimeService : IRealtimeService | ||||
|             .WithMetadata(JsonSerializer.Serialize(new Dictionary<string, string> | ||||
|                 { { "account_id", account.Id.ToString() } })) | ||||
|             .WithTtl(TimeSpan.FromHours(1)); | ||||
|         return token.ToJwt(); | ||||
|         return Task.FromResult(token.ToJwt()); | ||||
|     } | ||||
| 
 | ||||
|     public async Task ReceiveWebhook(string body, string authHeader) | ||||
| @@ -159,7 +164,8 @@ public class LivekitRealtimeService : IRealtimeService | ||||
|                         evt.Room.Name, evt.Participant.Identity); | ||||
| 
 | ||||
|                     // Broadcast participant list update to all participants | ||||
|                     await _BroadcastParticipantUpdate(evt.Room.Name); | ||||
|                     var info = await GetRoomParticipantsAsync(evt.Room.Name); | ||||
|                     await _callStatus.BroadcastParticipantUpdate(evt.Room.Name, info); | ||||
|                 } | ||||
| 
 | ||||
|                 break; | ||||
| @@ -174,14 +180,15 @@ public class LivekitRealtimeService : IRealtimeService | ||||
|                         evt.Room.Name, evt.Participant.Identity); | ||||
| 
 | ||||
|                     // Broadcast participant list update to all participants | ||||
|                     await _BroadcastParticipantUpdate(evt.Room.Name); | ||||
|                     var info = await GetRoomParticipantsAsync(evt.Room.Name); | ||||
|                     await _callStatus.BroadcastParticipantUpdate(evt.Room.Name, info); | ||||
|                 } | ||||
| 
 | ||||
|                 break; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private static string _GetParticipantsKey(string roomName) | ||||
|      | ||||
|         private static string _GetParticipantsKey(string roomName) | ||||
|         => $"RoomParticipants_{roomName}"; | ||||
| 
 | ||||
|     private async Task _AddParticipantToCache(string roomName, ParticipantInfo participant) | ||||
| @@ -201,7 +208,7 @@ public class LivekitRealtimeService : IRealtimeService | ||||
|         } | ||||
| 
 | ||||
|         // Get the current participants list | ||||
|         var participants = await _cache.GetAsync<List<ParticipantCacheItem>>(participantsKey) ?? | ||||
|         var participants = await _cache.GetAsync<List<ParticipantInfoItem>>(participantsKey) ?? | ||||
|                            []; | ||||
| 
 | ||||
|         // Check if the participant already exists | ||||
| @@ -241,7 +248,7 @@ public class LivekitRealtimeService : IRealtimeService | ||||
|         } | ||||
| 
 | ||||
|         // Get current participants list | ||||
|         var participants = await _cache.GetAsync<List<ParticipantCacheItem>>(participantsKey); | ||||
|         var participants = await _cache.GetAsync<List<ParticipantInfoItem>>(participantsKey); | ||||
|         if (participants == null || !participants.Any()) | ||||
|             return; | ||||
| 
 | ||||
| @@ -253,36 +260,24 @@ public class LivekitRealtimeService : IRealtimeService | ||||
|     } | ||||
| 
 | ||||
|     // Helper method to get participants in a room | ||||
|     public async Task<List<ParticipantCacheItem>> GetRoomParticipantsAsync(string roomName) | ||||
|     public async Task<List<ParticipantInfoItem>> GetRoomParticipantsAsync(string roomName) | ||||
|     { | ||||
|         var participantsKey = _GetParticipantsKey(roomName); | ||||
|         return await _cache.GetAsync<List<ParticipantCacheItem>>(participantsKey) ?? []; | ||||
|         return await _cache.GetAsync<List<ParticipantInfoItem>>(participantsKey) ?? []; | ||||
|     } | ||||
| 
 | ||||
|     // Class to represent a participant in the cache | ||||
|     public class ParticipantCacheItem | ||||
|     { | ||||
|         public string Identity { get; set; } = null!; | ||||
|         public string Name { get; set; } = null!; | ||||
|         public Guid? AccountId { get; set; } | ||||
|         public ParticipantInfo.Types.State State { get; set; } | ||||
|         public Dictionary<string, string> Metadata { get; set; } = new(); | ||||
|         public DateTime JoinedAt { get; set; } | ||||
|     } | ||||
| 
 | ||||
|     private ParticipantCacheItem CreateParticipantCacheItem(ParticipantInfo participant) | ||||
|     private ParticipantInfoItem CreateParticipantCacheItem(ParticipantInfo participant) | ||||
|     { | ||||
|         // Try to parse account ID from metadata | ||||
|         Guid? accountId = null; | ||||
|         var metadata = new Dictionary<string, string>(); | ||||
| 
 | ||||
|         if (string.IsNullOrEmpty(participant.Metadata)) | ||||
|             return new ParticipantCacheItem | ||||
|             return new ParticipantInfoItem | ||||
|             { | ||||
|                 Identity = participant.Identity, | ||||
|                 Name = participant.Name, | ||||
|                 AccountId = accountId, | ||||
|                 State = participant.State, | ||||
|                 Metadata = metadata, | ||||
|                 JoinedAt = DateTime.UtcNow | ||||
|             }; | ||||
| @@ -300,92 +295,13 @@ public class LivekitRealtimeService : IRealtimeService | ||||
|             _logger.LogError(ex, "Failed to parse participant metadata"); | ||||
|         } | ||||
| 
 | ||||
|         return new ParticipantCacheItem | ||||
|         return new ParticipantInfoItem | ||||
|         { | ||||
|             Identity = participant.Identity, | ||||
|             Name = participant.Name, | ||||
|             AccountId = accountId, | ||||
|             State = participant.State, | ||||
|             Metadata = metadata, | ||||
|             JoinedAt = DateTime.UtcNow | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     // Broadcast participant update to all participants in a room | ||||
|     private async Task _BroadcastParticipantUpdate(string roomName) | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             // Get the room ID from the session name | ||||
|             var roomInfo = await _db.ChatRealtimeCall | ||||
|                 .Where(c => c.SessionId == roomName && c.EndedAt == null) | ||||
|                 .Select(c => new { c.RoomId, c.Id }) | ||||
|                 .FirstOrDefaultAsync(); | ||||
| 
 | ||||
|             if (roomInfo == null) | ||||
|             { | ||||
|                 _logger.LogWarning("Could not find room info for session: {SessionName}", roomName); | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             // Get current participants | ||||
|             var livekitParticipants = await GetRoomParticipantsAsync(roomName); | ||||
| 
 | ||||
|             // Get all room members who should receive this update | ||||
|             var roomMembers = await _db.ChatMembers | ||||
|                 .Where(m => m.ChatRoomId == roomInfo.RoomId && m.LeaveAt == null) | ||||
|                 .Select(m => m.AccountId) | ||||
|                 .ToListAsync(); | ||||
| 
 | ||||
|             // Get member profiles for participants who have account IDs | ||||
|             var accountIds = livekitParticipants | ||||
|                 .Where(p => p.AccountId.HasValue) | ||||
|                 .Select(p => p.AccountId!.Value) | ||||
|                 .ToList(); | ||||
| 
 | ||||
|             var memberProfiles = new Dictionary<Guid, ChatMember>(); | ||||
|             if (accountIds.Count != 0) | ||||
|             { | ||||
|                 memberProfiles = await _db.ChatMembers | ||||
|                     .Where(m => m.ChatRoomId == roomInfo.RoomId && accountIds.Contains(m.AccountId)) | ||||
|                     .Include(m => m.Account) | ||||
|                     .ThenInclude(m => m.Profile) | ||||
|                     .ToDictionaryAsync(m => m.AccountId, m => m); | ||||
|             } | ||||
| 
 | ||||
|             // Convert to CallParticipant objects | ||||
|             var participants = livekitParticipants.Select(p => new CallParticipant | ||||
|             { | ||||
|                 Identity = p.Identity, | ||||
|                 Name = p.Name, | ||||
|                 AccountId = p.AccountId, | ||||
|                 JoinedAt = p.JoinedAt, | ||||
|                 Profile = p.AccountId.HasValue && memberProfiles.TryGetValue(p.AccountId.Value, out var profile) | ||||
|                     ? profile | ||||
|                     : null | ||||
|             }).ToList(); | ||||
| 
 | ||||
|             // Create the update packet with CallParticipant objects | ||||
|             var updatePacket = new WebSocketPacket | ||||
|             { | ||||
|                 Type = WebSocketPacketType.CallParticipantsUpdate, | ||||
|                 Data = new Dictionary<string, object> | ||||
|                 { | ||||
|                     { "room_id", roomInfo.RoomId }, | ||||
|                     { "call_id", roomInfo.Id }, | ||||
|                     { "participants", participants } | ||||
|                 } | ||||
|             }; | ||||
| 
 | ||||
|             // Send the update to all members | ||||
|             foreach (var accountId in roomMembers) | ||||
|             { | ||||
|                 _ws.SendPacketToAccount(accountId, updatePacket); | ||||
|             } | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             _logger.LogError(ex, "Error broadcasting participant update for room {RoomName}", roomName); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| } | ||||
							
								
								
									
										91
									
								
								DysonNetwork.Sphere/Chat/Realtime/RealtimeStatusService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								DysonNetwork.Sphere/Chat/Realtime/RealtimeStatusService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,91 @@ | ||||
| using DysonNetwork.Sphere.Connection; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Chat.Realtime; | ||||
|  | ||||
| public class ParticipantInfoItem | ||||
| { | ||||
|     public string Identity { get; set; } = null!; | ||||
|     public string Name { get; set; } = null!; | ||||
|     public Guid? AccountId { get; set; } | ||||
|     public Dictionary<string, string> Metadata { get; set; } = new(); | ||||
|     public DateTime JoinedAt { get; set; } | ||||
| } | ||||
|  | ||||
| public class RealtimeStatusService(AppDatabase db, WebSocketService ws, ILogger<RealtimeStatusService> logger) | ||||
| { | ||||
|     // Broadcast participant update to all participants in a room | ||||
|     public async Task BroadcastParticipantUpdate(string roomName, List<ParticipantInfoItem> participantsInfo) | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             // Get the room ID from the session name | ||||
|             var roomInfo = await db.ChatRealtimeCall | ||||
|                 .Where(c => c.SessionId == roomName && c.EndedAt == null) | ||||
|                 .Select(c => new { c.RoomId, c.Id }) | ||||
|                 .FirstOrDefaultAsync(); | ||||
|  | ||||
|             if (roomInfo == null) | ||||
|             { | ||||
|                 logger.LogWarning("Could not find room info for session: {SessionName}", roomName); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             // Get all room members who should receive this update | ||||
|             var roomMembers = await db.ChatMembers | ||||
|                 .Where(m => m.ChatRoomId == roomInfo.RoomId && m.LeaveAt == null) | ||||
|                 .Select(m => m.AccountId) | ||||
|                 .ToListAsync(); | ||||
|  | ||||
|             // Get member profiles for participants who have account IDs | ||||
|             var accountIds = participantsInfo | ||||
|                 .Where(p => p.AccountId.HasValue) | ||||
|                 .Select(p => p.AccountId!.Value) | ||||
|                 .ToList(); | ||||
|  | ||||
|             var memberProfiles = new Dictionary<Guid, ChatMember>(); | ||||
|             if (accountIds.Count != 0) | ||||
|             { | ||||
|                 memberProfiles = await db.ChatMembers | ||||
|                     .Where(m => m.ChatRoomId == roomInfo.RoomId && accountIds.Contains(m.AccountId)) | ||||
|                     .Include(m => m.Account) | ||||
|                     .ThenInclude(m => m.Profile) | ||||
|                     .ToDictionaryAsync(m => m.AccountId, m => m); | ||||
|             } | ||||
|  | ||||
|             // Convert to CallParticipant objects | ||||
|             var participants = participantsInfo.Select(p => new CallParticipant | ||||
|             { | ||||
|                 Identity = p.Identity, | ||||
|                 Name = p.Name, | ||||
|                 AccountId = p.AccountId, | ||||
|                 JoinedAt = p.JoinedAt, | ||||
|                 Profile = p.AccountId.HasValue && memberProfiles.TryGetValue(p.AccountId.Value, out var profile) | ||||
|                     ? profile | ||||
|                     : null | ||||
|             }).ToList(); | ||||
|  | ||||
|             // Create the update packet with CallParticipant objects | ||||
|             var updatePacket = new WebSocketPacket | ||||
|             { | ||||
|                 Type = WebSocketPacketType.CallParticipantsUpdate, | ||||
|                 Data = new Dictionary<string, object> | ||||
|                 { | ||||
|                     { "room_id", roomInfo.RoomId }, | ||||
|                     { "call_id", roomInfo.Id }, | ||||
|                     { "participants", participants } | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             // Send the update to all members | ||||
|             foreach (var accountId in roomMembers) | ||||
|             { | ||||
|                 ws.SendPacketToAccount(accountId, updatePacket); | ||||
|             } | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             logger.LogError(ex, "Error broadcasting participant update for room {RoomName}", roomName); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,5 +1,4 @@ | ||||
| using DysonNetwork.Sphere.Chat.Realtime; | ||||
| using Livekit.Server.Sdk.Dotnet; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| @@ -97,34 +96,6 @@ public class RealtimeCallController( | ||||
|         var endpoint = _config.Endpoint ?? | ||||
|                    throw new InvalidOperationException("LiveKit endpoint configuration is missing"); | ||||
|  | ||||
|         // Inject the ChatRoomService | ||||
|         var chatRoomService = HttpContext.RequestServices.GetRequiredService<ChatRoomService>(); | ||||
|  | ||||
|         // Get current participants from the LiveKit service | ||||
|         var participants = new List<CallParticipant>(); | ||||
|         if (realtime is LivekitRealtimeService livekitService) | ||||
|         { | ||||
|             var roomParticipants = await livekitService.GetRoomParticipantsAsync(ongoingCall.SessionId); | ||||
|             participants = []; | ||||
|              | ||||
|             foreach (var p in roomParticipants) | ||||
|             { | ||||
|                 var participant = new CallParticipant | ||||
|                 { | ||||
|                     Identity = p.Identity, | ||||
|                     Name = p.Name, | ||||
|                     AccountId = p.AccountId, | ||||
|                     JoinedAt = p.JoinedAt | ||||
|                 }; | ||||
|              | ||||
|                 // Fetch the ChatMember profile if we have an account ID | ||||
|                 if (p.AccountId.HasValue) | ||||
|                     participant.Profile = await chatRoomService.GetRoomMember(p.AccountId.Value, roomId); | ||||
|              | ||||
|                 participants.Add(participant); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Create the response model | ||||
|         var response = new JoinCallResponse | ||||
|         { | ||||
| @@ -133,8 +104,7 @@ public class RealtimeCallController( | ||||
|             Token = userToken, | ||||
|             CallId = ongoingCall.Id, | ||||
|             RoomName = ongoingCall.SessionId, | ||||
|             IsAdmin = isAdmin, | ||||
|             Participants = participants | ||||
|             IsAdmin = isAdmin | ||||
|         }; | ||||
|  | ||||
|         return Ok(response); | ||||
| @@ -215,11 +185,6 @@ public class JoinCallResponse | ||||
|     /// Whether the user is the admin of the call | ||||
|     /// </summary> | ||||
|     public bool IsAdmin { get; set; } | ||||
|      | ||||
|     /// <summary> | ||||
|     /// Current participants in the call | ||||
|     /// </summary> | ||||
|     public List<CallParticipant> Participants { get; set; } = new(); | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| @@ -251,4 +216,4 @@ public class CallParticipant | ||||
|     /// When the participant joined the call | ||||
|     /// </summary> | ||||
|     public DateTime JoinedAt { get; set; } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -229,7 +229,8 @@ public static class ServiceCollectionExtensions | ||||
|         services.AddScoped<WalletService>(); | ||||
|         services.AddScoped<SubscriptionService>(); | ||||
|         services.AddScoped<PaymentService>(); | ||||
|         services.AddScoped<IRealtimeService, LivekitRealtimeService>(); | ||||
|         services.AddScoped<RealtimeStatusService>(); | ||||
|         services.AddRealtimeService(configuration); | ||||
|         services.AddScoped<WebReaderService>(); | ||||
|         services.AddScoped<WebFeedService>(); | ||||
|         services.AddScoped<AfdianPaymentHandler>(); | ||||
| @@ -242,4 +243,22 @@ public static class ServiceCollectionExtensions | ||||
|  | ||||
|         return services; | ||||
|     } | ||||
| } | ||||
|      | ||||
|     private static IServiceCollection AddRealtimeService(this IServiceCollection services, IConfiguration configuration) | ||||
|     { | ||||
|         var provider = configuration["Realtime:Provider"]; | ||||
|         switch (provider) | ||||
|         { | ||||
|             case "Cloudflare": | ||||
|                 services.AddHttpClient<IRealtimeService, CloudflareRealtimeService>(); | ||||
|                 break; | ||||
|             case "LiveKit": | ||||
|                 services.AddScoped<IRealtimeService, LiveKitRealtimeService>(); | ||||
|                 break; | ||||
|             default: | ||||
|                 throw new NotSupportedException($"Realtime provider '{provider}' is not supported."); | ||||
|         } | ||||
|  | ||||
|         return services; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -85,10 +85,18 @@ | ||||
|     "FromName": "Alphabot", | ||||
|     "SubjectPrefix": "Solar Network" | ||||
|   }, | ||||
|   "RealtimeChat": { | ||||
|     "Endpoint": "https://solar-network-im44o8gq.livekit.cloud", | ||||
|     "ApiKey": "APIs6TiL8wj3A4j", | ||||
|     "ApiSecret": "SffxRneIwTnlHPtEf3zicmmv3LUEl7xXael4PvWZrEhE" | ||||
|   "Realtime": { | ||||
|     "Provider": "LiveKit", | ||||
|     "LiveKit": { | ||||
|       "Endpoint": "https://solar-network-im44o8gq.livekit.cloud", | ||||
|       "ApiKey": "APIs6TiL8wj3A4j", | ||||
|       "ApiSecret": "SffxRneIwTnlHPtEf3zicmmv3LUEl7xXael4PvWZrEhE" | ||||
|     }, | ||||
|     "Cloudflare": { | ||||
|       "ApiKey": "", | ||||
|       "ApiSecret": "", | ||||
|       "PreferredRegion": "us-east-1" | ||||
|     } | ||||
|   }, | ||||
|   "GeoIp": { | ||||
|     "DatabasePath": "./Keys/GeoLite2-City.mmdb" | ||||
|   | ||||
| @@ -56,6 +56,7 @@ | ||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJwtSecurityTokenHandler_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F477051138f1f40de9077b7b1cdc55c6215fb0_003Ff5_003Fd716e016_003FJwtSecurityTokenHandler_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AKestrelServerLimits_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F1e2e5dfcafad4407b569dd5df56a2fbf274e00_003Fa4_003F39445f62_003FKestrelServerLimits_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AKnownResamplers_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fef3339e864a448e2b1ec6fa7bbf4c6661fee00_003Fb3_003Fcdb3e080_003FKnownResamplers_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ALivekitModels_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Ff013807925809b234b3ca1be31567576bb8bda08f2c4fa5d290d4d14d8134_003FLivekitModels_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ALivekitRoom_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003F82666257d5ad47354add7af860f66dd85df55ec93e92e8a45891b9bff7bf80ac_003FLivekitRoom_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMailboxAddress_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8e03e47c46b7469f97abc40667cbcf9b133000_003Fa6_003F83324248_003FMailboxAddress_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMediaAnalysis_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffef366b36a224d469ff150d30f9a866d23c00_003Fd7_003F5c138865_003FMediaAnalysis_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user