🧱 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

@@ -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;
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)
);
// 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;
}
if (evt is null) return Task.CompletedTask;
// TODO: Handle webhook events
return Task.CompletedTask;
}
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);
}
}
}
}