Tests / test (push) Canceled after 0s
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Adapt video editing, decrypted message actions, custom themes and gestures from PR 166. Track visible server presence per session and preserve delivery for invisible clients.
392 lines
14 KiB
C#
392 lines
14 KiB
C#
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> 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<OutgoingCallDto> 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<OutgoingCallDto> 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<Chat> 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<List<PushDevice>> 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}";
|
|
}
|