using Ikar.Server.Data; using Ikar.Server.Data.Entities; using Ikar.Server.Infrastructure.Auth; using Ikar.Server.Infrastructure.Calls; using Ikar.Server.Infrastructure.Push; using Ikar.Server.Infrastructure; using Ikar.Shared; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using System.Text.Json; namespace Ikar.Server.Infrastructure.Hubs; [Authorize] public sealed class MessengerHub( PresenceTracker presenceTracker, IkarDbContext dbContext, CallSessionStore callSessionStore, IPushNotificationSender pushNotificationSender, IOptions webRtcOptions, QlyraClientIdMapper qlyraClientIdMapper) : Hub { public override async Task OnConnectedAsync() { var userId = Context.User!.GetRequiredUserId(); await Groups.AddToGroupAsync(Context.ConnectionId, GetUserGroup(userId)); var interactive = !string.Equals(Context.GetHttpContext()?.Request.Query["interactive"].ToString(), "false", StringComparison.OrdinalIgnoreCase); presenceTracker.Connect(userId, Context.User!.GetRequiredSessionId(), Context.ConnectionId, interactive); if (interactive) { var user = await dbContext.Users.SingleAsync(user => user.Id == userId, Context.ConnectionAborted); user.PublicLastSeenUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); await dbContext.SaveChangesAsync(Context.ConnectionAborted); } await CaptureClientVersionAsync(); await base.OnConnectedAsync(); } public override async Task OnDisconnectedAsync(Exception? exception) { if (Context.User?.Identity?.IsAuthenticated == true) { var userId = Context.User.GetRequiredUserId(); presenceTracker.Disconnect(userId, Context.ConnectionId); if (callSessionStore.TryEndByParticipant(userId, "disconnected", out var endedSession)) { await NotifyCallEndedAsync(endedSession!); } } await base.OnDisconnectedAsync(exception); } public Task StartCall(Guid chatId) => StartMediaCall(chatId, CallMediaType.Audio); public async Task SendTyping(Guid chatId, bool isTyping) { var userId = Context.User!.GetRequiredUserId(); var chat = await dbContext.Chats .Include(x => x.Members) .ThenInclude(x => x.User) .AsNoTracking() .SingleOrDefaultAsync( x => x.Id == chatId && x.Members.Any(member => member.UserId == userId), Context.ConnectionAborted); if (chat is null) { throw new HubException("Chat not found."); } if (chat.Type == ChatType.Channel && !DtoMapper.BuildPermissions(chat.Members.FirstOrDefault(member => member.UserId == userId), chat.Type).CanPostMessages) { throw new HubException("Read-only channel."); } var sender = chat.Members.Single(member => member.UserId == userId).User; var payload = new TypingIndicatorDto(chatId, sender.ToDto(presenceTracker), isTyping, DateTimeOffset.UtcNow); var tasks = chat.Members .Where(member => member.UserId != userId) .Select(member => Clients.User(member.UserId.ToString()) .SendAsync("TypingChanged", payload, Context.ConnectionAborted)); await Task.WhenAll(tasks); } public async Task StartMediaCall(Guid chatId, CallMediaType mediaType) { var callerId = Context.User!.GetRequiredUserId(); var chat = await LoadDirectChatAsync(chatId, callerId); var caller = chat.Members.Single(member => member.UserId == callerId).User; var callee = chat.Members.Single(member => member.UserId != callerId).User; var calleeDevices = await LoadPushDevicesAsync(callee.Id); var hasRealtimePresence = presenceTracker.IsConnected(callee.Id); var hasPushDevices = calleeDevices.Count > 0; if (!hasRealtimePresence && !hasPushDevices) { throw new HubException("Собеседник сейчас недоступен для звонка."); } if (!callSessionStore.TryCreate(chatId, callerId, callee.Id, mediaType, out var session, out var error)) { throw new HubException(error); } var callerDto = caller.ToDto(presenceTracker); var calleeDto = callee.ToDto(presenceTracker); var resolvedSession = session!; var incomingCall = new IncomingCallDto(resolvedSession.CallId, chatId, callerDto, mediaType); if (hasRealtimePresence) { await Clients.Group(GetUserGroup(callee.Id)) .SendAsync("IncomingCall", incomingCall); } if (hasPushDevices) { await pushNotificationSender.SendIncomingCallAsync(incomingCall, calleeDevices, Context.ConnectionAborted); } return new OutgoingCallDto(resolvedSession.CallId, chatId, calleeDto, mediaType); } public async Task AcceptCall(Guid callId) { var userId = Context.User!.GetRequiredUserId(); if (!callSessionStore.TryAccept(callId, userId, out var session, out var error)) { throw new HubException(error); } var resolvedSession = session!; var connectedAt = resolvedSession.ConnectedAt ?? DateTimeOffset.UtcNow; await NotifyCallConnectedAsync(resolvedSession with { ConnectedAt = connectedAt }); } public async Task DeclineCall(Guid callId) { var userId = Context.User!.GetRequiredUserId(); if (!callSessionStore.TryDecline(callId, userId, out var session, out var error)) { throw new HubException(error); } await NotifyCallEndedAsync(session!); } public async Task EndCall(Guid callId) { var userId = Context.User!.GetRequiredUserId(); if (!callSessionStore.TryEnd(callId, userId, "ended", out var session, out var error)) { throw new HubException(error); } await NotifyCallEndedAsync(session!); } public async Task SendCallOffer(CallOfferDto payload) { if (string.IsNullOrWhiteSpace(payload.Sdp) || string.IsNullOrWhiteSpace(payload.Type)) { throw new HubException("Пустое WebRTC offer."); } var recipientUserId = ResolveRecipientForConnectedCall(payload.CallId); await Clients.Group(GetUserGroup(recipientUserId)) .SendAsync("CallOffer", payload); } public async Task SendCallAnswer(CallAnswerDto payload) { if (string.IsNullOrWhiteSpace(payload.Sdp) || string.IsNullOrWhiteSpace(payload.Type)) { throw new HubException("Пустой WebRTC answer."); } var recipientUserId = ResolveRecipientForConnectedCall(payload.CallId); await Clients.Group(GetUserGroup(recipientUserId)) .SendAsync("CallAnswer", payload); } public async Task SendCallIceCandidate(CallIceCandidateDto payload) { if (string.IsNullOrWhiteSpace(payload.Candidate)) { throw new HubException("Пустой ICE candidate."); } var recipientUserId = ResolveRecipientForConnectedCall(payload.CallId); await Clients.Group(GetUserGroup(recipientUserId)) .SendAsync("CallIceCandidate", payload); } private Guid ResolveRecipientForConnectedCall(Guid callId) { var userId = Context.User!.GetRequiredUserId(); if (!callSessionStore.TryResolveRecipient(callId, userId, out var recipientUserId, out _, out var error)) { throw new HubException(error); } return recipientUserId; } private async Task LoadDirectChatAsync(Guid chatId, Guid userId) { var chat = await dbContext.Chats .Include(x => x.Members) .ThenInclude(x => x.User) .SingleOrDefaultAsync(x => x.Id == chatId); if (chat is null) { throw new HubException("Чат не найден."); } if (chat.Type != ChatType.Direct) { throw new HubException("Звонки доступны только в личных чатах."); } if (!chat.Members.Any(member => member.UserId == userId)) { throw new HubException("Нет доступа к этому чату."); } if (chat.Members.Count != 2) { throw new HubException("Для звонка требуется один собеседник."); } if (chat.Members.Any(member => member.User.IsBot)) { throw new HubException("Звонки недоступны в чатах с ботами."); } return chat; } private Task NotifyCallConnectedAsync(ActiveCallSession session) { var iceServers = webRtcOptions.Value.ToDto(); var callerPayload = new CallConnectedDto( session.CallId, session.ChatId, session.ConnectedAt ?? DateTimeOffset.UtcNow, session.MediaType, iceServers, ShouldCreateOffer: true); var calleePayload = new CallConnectedDto( session.CallId, session.ChatId, session.ConnectedAt ?? DateTimeOffset.UtcNow, session.MediaType, iceServers, ShouldCreateOffer: false); return Task.WhenAll( Clients.Group(GetUserGroup(session.CallerId)).SendAsync("CallConnected", callerPayload), Clients.Group(GetUserGroup(session.CalleeId)).SendAsync("CallConnected", calleePayload)); } private async Task NotifyCallEndedAsync(ActiveCallSession session) { var payload = new CallEndedDto(session.CallId, session.ChatId, session.EndReason, session.MediaType); await Task.WhenAll( Clients.Group(GetUserGroup(session.CallerId)).SendAsync("CallEnded", payload), Clients.Group(GetUserGroup(session.CalleeId)).SendAsync("CallEnded", payload)); await PersistCallHistoryMessageAsync(session); var calleeDevices = await LoadPushDevicesAsync(session.CalleeId); if (calleeDevices.Count > 0) { await pushNotificationSender.SendCallEndedAsync(payload, calleeDevices, Context.ConnectionAborted); } } private async Task PersistCallHistoryMessageAsync(ActiveCallSession session) { var chat = await dbContext.Chats .Include(x => x.Members) .SingleOrDefaultAsync(x => x.Id == session.ChatId, Context.ConnectionAborted); if (chat is null || chat.Members.Count == 0) { return; } var sender = await dbContext.Users .SingleOrDefaultAsync(x => x.Id == session.CallerId, Context.ConnectionAborted); if (sender is null) { return; } var sentAt = DateTimeOffset.UtcNow; var calleeClientId = await qlyraClientIdMapper.GetOrCreateAsync("user", session.CalleeId, Context.ConnectionAborted); var duration = session.ConnectedAt is null ? 0L : Math.Max(0L, (sentAt - session.ConnectedAt.Value).Ticks / TimeSpan.TicksPerMillisecond); var message = new Message { ChatId = chat.Id, Chat = chat, SenderId = sender.Id, Sender = sender, Text = BuildCallHistoryText(session), SentAt = sentAt, QlyraAttachesJson = JsonSerializer.Serialize(new[] { new { _type = "CALL", callType = session.MediaType == CallMediaType.Video ? "VIDEO" : "AUDIO", duration, hangupType = session.ConnectedAt is null ? session.EndReason == "declined" ? "REJECTED" : "MISSED" : "HUNGUP", conversationId = session.CallId.ToString("D"), contactIds = new[] { calleeClientId } } }) }; chat.LastActivityAt = sentAt; dbContext.Messages.Add(message); await dbContext.SaveChangesAsync(Context.ConnectionAborted); await NotifyMessageCreatedAsync(chat, message); } private Task NotifyMessageCreatedAsync(Chat chat, Message message) { var tasks = chat.Members.Select(async member => { var dto = message.ToDto(presenceTracker, member.UserId); await Clients.User(member.UserId.ToString()) .SendAsync("MessageCreated", dto, Context.ConnectionAborted); }); return Task.WhenAll(tasks); } private static string BuildCallHistoryText(ActiveCallSession session) { var callType = session.MediaType == CallMediaType.Video ? "Видеозвонок" : "Аудиозвонок"; if (session.ConnectedAt is not null) { return $"{callType} завершён"; } return session.EndReason switch { "declined" => "Вызов отклонён", _ => $"{callType} без ответа" }; } private Task> LoadPushDevicesAsync(Guid userId) => dbContext.PushDevices .Where(device => device.UserId == userId && device.NotificationsEnabled) .ToListAsync(Context.ConnectionAborted); private async Task CaptureClientVersionAsync() { var httpContext = Context.GetHttpContext(); if (httpContext is null) { return; } var sessionId = Context.User!.GetRequiredSessionId(); var session = await dbContext.UserSessions .SingleOrDefaultAsync(x => x.Id == sessionId, Context.ConnectionAborted); if (session is null) { return; } if (ClientVersionRequestReader.Apply(session, ClientVersionRequestReader.Read(httpContext.Request))) { await dbContext.SaveChangesAsync(Context.ConnectionAborted); } } private static string GetUserGroup(Guid userId) => $"user:{userId:D}"; }