🧱 Add cloudflare realtime service

This commit is contained in:
2025-07-11 21:07:38 +08:00
parent 2a3918134f
commit 7b026eeae1
7 changed files with 458 additions and 316 deletions

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

View File

@ -0,0 +1,315 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using Microsoft.IdentityModel.Tokens;
using System.Text.Json.Serialization;
namespace DysonNetwork.Sphere.Chat.Realtime;
public class CloudflareRealtimeService : IRealtimeService
{
private readonly HttpClient _httpClient;
private readonly IConfiguration _configuration;
private readonly string _apiSecret;
private readonly ChatRoomService _chatRoomService;
private RSA? _publicKey;
public CloudflareRealtimeService(HttpClient httpClient, IConfiguration configuration,
ChatRoomService chatRoomService)
{
_httpClient = httpClient;
_configuration = configuration;
_chatRoomService = chatRoomService;
var apiKey = _configuration["Realtime:Cloudflare:ApiKey"];
_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 = $"Call_{roomId.ToString().Replace("-", "")}";
var requestBody = new
{
title = $"Solar Room Call #{roomId}",
preferred_region = _configuration["Realtime:Cloudflare:PreferredRegion"],
data = metadata,
room_name = roomName
};
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<DyteMeetingResponse>(responseContent);
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<DyteResponse<DyteTokenResponse>>(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<DyteResponse<DyteParticipantResponse>>(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 webhookEvent = JsonSerializer.Deserialize<DyteWebhookEvent>(body);
if (webhookEvent.Type == "participant.joined")
{
await _chatRoomService.SetRoomCallStatus(
Guid.Parse(webhookEvent.Event.Meeting.RoomName.Replace("Call_", "")), true);
}
else if (webhookEvent.Type == "room.ended")
{
await _chatRoomService.SetRoomCallStatus(
Guid.Parse(webhookEvent.Event.Meeting.RoomName.Replace("Call_", "")), false);
}
}
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 DyteMeetingResponse
{
[JsonPropertyName("data")]
public DyteMeetingData Data { get; set; } = new();
}
private class DyteMeetingData
{
[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 DyteParticipant
{
public string Id { get; set; } = string.Empty;
public string Token { get; set; } = string.Empty;
public string CustomParticipantId { get; set; } = string.Empty;
}
public class DyteResponse<T>
{
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("data")]
public T? Data { get; set; }
}
public class DyteTokenResponse
{
[JsonPropertyName("token")]
public string Token { get; set; } = string.Empty;
}
public class DyteParticipantResponse
{
[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 DyteWebhookEvent
{
[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; }
}
}

View File

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

View File

@ -12,10 +12,6 @@ namespace DysonNetwork.Sphere.Chat.Realtime;
/// </summary>
public class LivekitRealtimeService : IRealtimeService
{
private readonly AppDatabase _db;
private readonly ICacheService _cache;
private readonly WebSocketService _ws;
private readonly ILogger<LivekitRealtimeService> _logger;
private readonly RoomServiceClient _roomService;
private readonly AccessToken _accessToken;
@ -23,29 +19,21 @@ public class LivekitRealtimeService : IRealtimeService
public LivekitRealtimeService(
IConfiguration configuration,
ILogger<LivekitRealtimeService> logger,
AppDatabase db,
ICacheService cache,
WebSocketService ws
)
ILogger<LivekitRealtimeService> logger)
{
_logger = logger;
// Get LiveKit configuration from appsettings
var host = configuration["RealtimeChat:Endpoint"] ??
var host = configuration["Livekit:Endpoint"] ??
throw new ArgumentNullException("Endpoint configuration is required");
var apiKey = configuration["RealtimeChat:ApiKey"] ??
var apiKey = configuration["Livekit:ApiKey"] ??
throw new ArgumentNullException("ApiKey configuration is required");
var apiSecret = configuration["RealtimeChat:ApiSecret"] ??
var apiSecret = configuration["Livekit:ApiSecret"] ??
throw new ArgumentNullException("ApiSecret configuration is required");
_roomService = new RoomServiceClient(host, apiKey, apiSecret);
_accessToken = new AccessToken(apiKey, apiSecret);
_webhookReceiver = new WebhookReceiver(apiKey, apiSecret);
_db = db;
_cache = cache;
_ws = ws;
}
/// <inheritdoc />
@ -112,6 +100,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,264 +121,16 @@ 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)
public Task ReceiveWebhook(string body, string authHeader)
{
var evt = _webhookReceiver.Receive(body, authHeader);
if (evt is null) return;
if (evt is null) return Task.CompletedTask;
switch (evt.Event)
{
case "room_finished":
var now = SystemClock.Instance.GetCurrentInstant();
await _db.ChatRealtimeCall
.Where(c => c.SessionId == evt.Room.Name)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.EndedAt, now)
);
// TODO: Handle webhook events
// Also clean up participants list when the room is finished
await _cache.RemoveAsync(_GetParticipantsKey(evt.Room.Name));
break;
case "participant_joined":
if (evt.Participant != null)
{
// Add the participant to cache
await _AddParticipantToCache(evt.Room.Name, evt.Participant);
_logger.LogInformation(
"Participant joined room: {RoomName}, Participant: {ParticipantIdentity}",
evt.Room.Name, evt.Participant.Identity);
// Broadcast participant list update to all participants
await _BroadcastParticipantUpdate(evt.Room.Name);
}
break;
case "participant_left":
if (evt.Participant != null)
{
// Remove the participant from cache
await _RemoveParticipantFromCache(evt.Room.Name, evt.Participant);
_logger.LogInformation(
"Participant left room: {RoomName}, Participant: {ParticipantIdentity}",
evt.Room.Name, evt.Participant.Identity);
// Broadcast participant list update to all participants
await _BroadcastParticipantUpdate(evt.Room.Name);
}
break;
}
}
private static string _GetParticipantsKey(string roomName)
=> $"RoomParticipants_{roomName}";
private async Task _AddParticipantToCache(string roomName, ParticipantInfo participant)
{
var participantsKey = _GetParticipantsKey(roomName);
// Try to acquire a lock to prevent race conditions when updating the participants list
await using var lockObj = await _cache.AcquireLockAsync(
$"{participantsKey}_lock",
TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(5));
if (lockObj == null)
{
_logger.LogWarning("Failed to acquire lock for updating participants list in room: {RoomName}", roomName);
return;
}
// Get the current participants list
var participants = await _cache.GetAsync<List<ParticipantCacheItem>>(participantsKey) ??
[];
// Check if the participant already exists
var existingIndex = participants.FindIndex(p => p.Identity == participant.Identity);
if (existingIndex >= 0)
{
// Update existing participant
participants[existingIndex] = CreateParticipantCacheItem(participant);
}
else
{
// Add new participant
participants.Add(CreateParticipantCacheItem(participant));
}
// Update cache with new list
await _cache.SetAsync(participantsKey, participants, TimeSpan.FromHours(6));
// Also add to a room group in cache for easy cleanup
await _cache.AddToGroupAsync(participantsKey, $"Room_{roomName}");
}
private async Task _RemoveParticipantFromCache(string roomName, ParticipantInfo participant)
{
var participantsKey = _GetParticipantsKey(roomName);
// Try to acquire a lock to prevent race conditions when updating the participants list
await using var lockObj = await _cache.AcquireLockAsync(
$"{participantsKey}_lock",
TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(5));
if (lockObj == null)
{
_logger.LogWarning("Failed to acquire lock for updating participants list in room: {RoomName}", roomName);
return;
}
// Get current participants list
var participants = await _cache.GetAsync<List<ParticipantCacheItem>>(participantsKey);
if (participants == null || !participants.Any())
return;
// Remove participant
participants.RemoveAll(p => p.Identity == participant.Identity);
// Update cache with new list
await _cache.SetAsync(participantsKey, participants, TimeSpan.FromHours(6));
}
// Helper method to get participants in a room
public async Task<List<ParticipantCacheItem>> GetRoomParticipantsAsync(string roomName)
{
var participantsKey = _GetParticipantsKey(roomName);
return await _cache.GetAsync<List<ParticipantCacheItem>>(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)
{
// Try to parse account ID from metadata
Guid? accountId = null;
var metadata = new Dictionary<string, string>();
if (string.IsNullOrEmpty(participant.Metadata))
return new ParticipantCacheItem
{
Identity = participant.Identity,
Name = participant.Name,
AccountId = accountId,
State = participant.State,
Metadata = metadata,
JoinedAt = DateTime.UtcNow
};
try
{
metadata = JsonSerializer.Deserialize<Dictionary<string, string>>(participant.Metadata) ??
new Dictionary<string, string>();
if (metadata.TryGetValue("account_id", out var accountIdStr))
if (Guid.TryParse(accountIdStr, out var parsedId))
accountId = parsedId;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse participant metadata");
}
return new ParticipantCacheItem
{
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);
}
return Task.CompletedTask;
}
}

View File

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

View File

@ -229,7 +229,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<WalletService>();
services.AddScoped<SubscriptionService>();
services.AddScoped<PaymentService>();
services.AddScoped<IRealtimeService, LivekitRealtimeService>();
services.AddRealtimeService(configuration);
services.AddScoped<WebReaderService>();
services.AddScoped<WebFeedService>();
services.AddScoped<AfdianPaymentHandler>();
@ -242,4 +242,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;
}
}

View File

@ -85,11 +85,19 @@
"FromName": "Alphabot",
"SubjectPrefix": "Solar Network"
},
"RealtimeChat": {
"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"
},