using Livekit.Server.Sdk.Dotnet; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Nuuru.Server.Data; using Nuuru.Server.DTOs.Messaging; namespace Nuuru.Server.Services { public interface IConversationVoiceService { bool IsEnabled { get; } Task GetStateAsync(Guid conversationId, Guid userId, CancellationToken cancellationToken = default); Task CreateJoinTokenAsync(Guid conversationId, Guid userId, CancellationToken cancellationToken = default); Task SetDeafenedAsync(Guid conversationId, Guid userId, bool deafened, CancellationToken cancellationToken = default); } public class ConversationVoiceService : IConversationVoiceService { private static readonly TimeSpan TokenTtl = TimeSpan.FromHours(1); private readonly ApplicationDbContext _context; private readonly LiveKitOptions _options; private readonly ILiveKitRoomAdminClient _roomAdminClient; public ConversationVoiceService( ApplicationDbContext context, IOptions options, ILiveKitRoomAdminClient roomAdminClient) { _context = context; _options = options.Value; _roomAdminClient = roomAdminClient; } public bool IsEnabled => _options.IsConfigured && _roomAdminClient.IsEnabled; public async Task GetStateAsync(Guid conversationId, Guid userId, CancellationToken cancellationToken = default) { var activeParticipants = await _context.ConversationParticipants .Where(participant => participant.ConversationId == conversationId && !participant.HasLeft) .Include(participant => participant.User) .Select(participant => new { participant.UserId, UserName = participant.User != null ? participant.User.UserName ?? "Unknown" : "Unknown", participant.JoinedAt }) .OrderBy(participant => participant.JoinedAt) .ToListAsync(cancellationToken); if (!activeParticipants.Any(participant => participant.UserId == userId)) { return null; } var roomName = GetRoomName(conversationId); if (!IsEnabled) { return new ConversationVoiceStateDto { Enabled = false, RoomName = roomName, ParticipantCount = 0, Participants = [] }; } var activeParticipantMap = activeParticipants.ToDictionary(participant => participant.UserId); var connectedUsers = new Dictionary(); foreach (var participant in await _roomAdminClient.ListParticipantsAsync(roomName, cancellationToken)) { if (!Guid.TryParse(participant.Identity, out var participantUserId)) { continue; } if (!activeParticipantMap.TryGetValue(participantUserId, out var activeParticipant)) { continue; } connectedUsers[participantUserId] = new ConversationVoiceParticipantDto { UserId = participantUserId, UserName = string.IsNullOrWhiteSpace(participant.Name) ? activeParticipant.UserName : participant.Name, IsMicrophoneEnabled = participant.IsMicrophoneEnabled, IsDeafened = participant.IsDeafened, }; } var orderedParticipants = activeParticipants .Where(participant => connectedUsers.ContainsKey(participant.UserId)) .Select(participant => connectedUsers[participant.UserId]) .ToList(); return new ConversationVoiceStateDto { Enabled = true, RoomName = roomName, ParticipantCount = orderedParticipants.Count, Participants = orderedParticipants }; } public async Task CreateJoinTokenAsync(Guid conversationId, Guid userId, CancellationToken cancellationToken = default) { var participant = await _context.ConversationParticipants .Where(conversationParticipant => conversationParticipant.ConversationId == conversationId && conversationParticipant.UserId == userId && !conversationParticipant.HasLeft) .Include(conversationParticipant => conversationParticipant.User) .Select(conversationParticipant => new { conversationParticipant.UserId, UserName = conversationParticipant.User != null ? conversationParticipant.User.UserName ?? "Unknown" : "Unknown" }) .SingleOrDefaultAsync(cancellationToken); if (participant == null) { return null; } if (!IsEnabled) { throw new InvalidOperationException("Voice chat is not configured."); } var roomName = GetRoomName(conversationId); var token = new AccessToken(_options.ApiKey!, _options.ApiSecret!) .WithIdentity(participant.UserId.ToString()) .WithName(participant.UserName) .WithGrants(new VideoGrants { RoomJoin = true, Room = roomName, CanPublish = true, CanSubscribe = true, CanPublishData = false }) .WithTtl(TokenTtl) .ToJwt(); return new ConversationVoiceTokenDto { ServerUrl = _options.ServerUrl!, RoomName = roomName, Token = token, Participant = new ConversationVoiceParticipantDto { UserId = participant.UserId, UserName = participant.UserName } }; } public async Task SetDeafenedAsync(Guid conversationId, Guid userId, bool deafened, CancellationToken cancellationToken = default) { var isActiveParticipant = await _context.ConversationParticipants .AnyAsync(conversationParticipant => conversationParticipant.ConversationId == conversationId && conversationParticipant.UserId == userId && !conversationParticipant.HasLeft, cancellationToken); if (!isActiveParticipant) { return null; } if (!IsEnabled) { throw new InvalidOperationException("Voice chat is not configured."); } var roomName = GetRoomName(conversationId); return await _roomAdminClient.SetParticipantDeafenedAsync(roomName, userId.ToString(), deafened, cancellationToken); } public static string GetRoomName(Guid conversationId) { return $"dm-{conversationId:N}"; } } }