Перепиливание под чистый DDD
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace Knot.Modules.Conversations.Infrastructure.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// Обработчик доменного события создания чата.
|
||||
/// </summary>
|
||||
public sealed class ChatCreatedDomainEventHandler : INotificationHandler<ChatCreatedDomainEvent>, INotificationHandler<ChatMemberAddedDomainEvent>
|
||||
{
|
||||
private readonly IHubContext<ChatHub> _hubContext;
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IUserDisplayNameProvider _displayNameProvider;
|
||||
|
||||
public ChatCreatedDomainEventHandler(
|
||||
IHubContext<ChatHub> hubContext,
|
||||
IChatRepository chatRepository,
|
||||
IUserDisplayNameProvider displayNameProvider)
|
||||
{
|
||||
_hubContext = hubContext;
|
||||
_chatRepository = chatRepository;
|
||||
_displayNameProvider = displayNameProvider;
|
||||
}
|
||||
|
||||
public async Task Handle(ChatCreatedDomainEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
await NotifyNewChatAsync(notification.Chat, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task Handle(ChatMemberAddedDomainEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await _chatRepository.GetByIdAsync(notification.ChatId, cancellationToken);
|
||||
if (chat != null)
|
||||
{
|
||||
// Уведомляем всех участников, что кто-то добавлен (или пользователь сам видит новый чат)
|
||||
await NotifyNewChatAsync(chat, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task NotifyNewChatAsync(Chat chat, CancellationToken cancellationToken, string? targetUserId = null)
|
||||
{
|
||||
var members = new List<object>();
|
||||
foreach (var member in chat.Members)
|
||||
{
|
||||
var userInfo = await _displayNameProvider.GetUserInfoAsync(member.UserId, cancellationToken);
|
||||
members.Add(new
|
||||
{
|
||||
member.Id,
|
||||
member.UserId,
|
||||
member.Role,
|
||||
member.JoinedAt,
|
||||
User = userInfo != null
|
||||
? new { userInfo.Id, userInfo.Username, userInfo.DisplayName, userInfo.Avatar, isOnline = false }
|
||||
: new { Id = member.UserId, Username = "unknown", DisplayName = "Unknown", Avatar = (string?)null, isOnline = false }
|
||||
});
|
||||
}
|
||||
|
||||
var payload = new
|
||||
{
|
||||
id = chat.Id,
|
||||
type = chat.Type.ToString().ToLowerInvariant(),
|
||||
name = chat.Type == ChatType.Favorites ? "Избранное" : (chat.Type == ChatType.Personal ? null : chat.Name),
|
||||
avatar = chat.Avatar,
|
||||
createdAt = chat.CreatedAt,
|
||||
members = members,
|
||||
messages = new List<object>(),
|
||||
unreadCount = 0
|
||||
};
|
||||
|
||||
if (targetUserId != null)
|
||||
{
|
||||
// Отправляем конкретному пользователю (по всем его соединениям)
|
||||
// Мы можем использовать метод Hub или просто отправить в группу UserId (если она есть)
|
||||
// В нашем ChatHub мы добавляем пользователя в группы чатов при подключении.
|
||||
// Но для нового чата он еще не в группе.
|
||||
// Поэтому отправляем через User ID (SignalR поддерживает Clients.User(id))
|
||||
await _hubContext.Clients.User(targetUserId).SendAsync("new_chat", payload, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Уведомляем всех текущих участников
|
||||
foreach (var member in chat.Members)
|
||||
{
|
||||
await _hubContext.Clients.User(member.UserId.ToString()).SendAsync("new_chat", payload, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
|
||||
namespace Knot.Modules.Conversations.Infrastructure.Persistence;
|
||||
|
||||
public sealed class ChatRepository : IChatRepository
|
||||
{
|
||||
private readonly ChatsDbContext _dbContext;
|
||||
|
||||
public ChatRepository(ChatsDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
public void Add(Chat chat)
|
||||
{
|
||||
_dbContext.Chats.Add(chat);
|
||||
}
|
||||
|
||||
public void Update(Chat chat)
|
||||
{
|
||||
_dbContext.Chats.Update(chat);
|
||||
}
|
||||
|
||||
public void Remove(Chat chat)
|
||||
{
|
||||
_dbContext.Chats.Remove(chat);
|
||||
}
|
||||
|
||||
public async Task<Chat?> GetByIdAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _dbContext.Chats
|
||||
.Include(c => c.Members)
|
||||
.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Chat?> GetFavoritesAsync(Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _dbContext.Chats
|
||||
.Include(c => c.Members)
|
||||
.FirstOrDefaultAsync(c =>
|
||||
c.Type == ChatType.Favorites &&
|
||||
c.Members.Any(m => m.UserId == userId),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<Chat>> GetUserChatsAsync(Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _dbContext.Chats
|
||||
.Include(c => c.Members)
|
||||
.Where(c => c.Members.Any(m => m.UserId == userId))
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Shared.Kernel.Security;
|
||||
|
||||
namespace Knot.Modules.Conversations.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Контекст базы данных для модуля чатов.
|
||||
/// </summary>
|
||||
public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IEncryptionService _encryptionService;
|
||||
|
||||
public ChatsDbContext(DbContextOptions<ChatsDbContext> options, IMediator mediator, IEncryptionService encryptionService)
|
||||
: base(options)
|
||||
{
|
||||
_mediator = mediator;
|
||||
_encryptionService = encryptionService;
|
||||
}
|
||||
|
||||
public DbSet<Chat> Chats => Set<Chat>();
|
||||
|
||||
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
base.OnConfiguring(optionsBuilder);
|
||||
optionsBuilder.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning));
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema("chats");
|
||||
|
||||
modelBuilder.Entity<Chat>(builder =>
|
||||
{
|
||||
builder.ToTable("Chats");
|
||||
builder.HasKey(c => c.Id);
|
||||
builder.Property(c => c.Type).HasConversion<string>();
|
||||
|
||||
|
||||
builder.OwnsMany(c => c.Members, mb =>
|
||||
{
|
||||
mb.ToTable("ChatMembers");
|
||||
mb.HasKey(m => m.Id);
|
||||
mb.WithOwner().HasForeignKey(m => m.ChatId);
|
||||
mb.HasIndex(m => new { m.ChatId, m.UserId }).IsUnique();
|
||||
}).Navigation(c => c.Members).UsePropertyAccessMode(PropertyAccessMode.Field);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. Получаем все события из агрегатов
|
||||
var domainEvents = ChangeTracker
|
||||
.Entries<IAggregateRoot>()
|
||||
.SelectMany(x =>
|
||||
|
||||
{
|
||||
if (x.Entity is AggregateRoot<Guid> root)
|
||||
{
|
||||
var events = root.GetDomainEvents().ToList();
|
||||
root.ClearDomainEvents();
|
||||
return events;
|
||||
}
|
||||
return Enumerable.Empty<IDomainEvent>();
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// 2. Сохраняем изменения
|
||||
int result = await base.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 3. Публикуем события через MediatR
|
||||
foreach (var domainEvent in domainEvents)
|
||||
{
|
||||
await _mediator.Publish(domainEvent, cancellationToken);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Knot.Modules.Conversations.Infrastructure.Services;
|
||||
using Knot.Modules.Messaging.Application.Abstractions;
|
||||
using Knot.Modules.Conversations.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
public class ChatAccessProvider : IChatAccessProvider { private readonly ChatsDbContext _db; public ChatAccessProvider(ChatsDbContext db) { _db = db; } public Task<List<Guid>> GetValidChatIdsForUserAsync(Guid userId, CancellationToken ct) { return _db.Chats.Where(c => c.Members.Any(m => m.UserId == userId)).Select(c => c.Id).ToListAsync(ct); } }
|
||||
|
||||
@@ -0,0 +1,664 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Claims;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Knot.Modules.Conversations.Application.Messages.Send;
|
||||
using Knot.Modules.Conversations.Application.Messages.Read;
|
||||
using Knot.Modules.Conversations.Application.Messages.Delete;
|
||||
using Knot.Modules.Conversations.Application.Messages.React;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||
|
||||
/// <summary>
|
||||
/// Хаб SignalR для обработки сообщений и WebRTC сигналинга в реальном времени.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public sealed class ChatHub : Hub
|
||||
{
|
||||
// Маппинг userId → список connectionId
|
||||
private static readonly ConcurrentDictionary<string, HashSet<string>> _userConnections = new();
|
||||
// chatId → (userId → ParticipantInfo)
|
||||
private static readonly ConcurrentDictionary<string, ConcurrentDictionary<string, ParticipantInfo>> _groupCallParticipants = new();
|
||||
|
||||
public static int OnlineUsersCount => _userConnections.Count;
|
||||
public static bool IsUserOnline(string userId) => _userConnections.ContainsKey(userId);
|
||||
|
||||
private readonly ISender _sender;
|
||||
private readonly IUserContext _userContext;
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly ILogger<ChatHub> _logger;
|
||||
private readonly IMemoryCache _cache;
|
||||
|
||||
public ChatHub(ISender sender, IUserContext userContext, IChatRepository chatRepository, ILogger<ChatHub> logger, IMemoryCache cache)
|
||||
{
|
||||
_sender = sender;
|
||||
_userContext = userContext;
|
||||
_chatRepository = chatRepository;
|
||||
_logger = logger;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
if (_userContext.IsAuthenticated)
|
||||
{
|
||||
var userId = _userContext.UserId.ToString();
|
||||
_userConnections.AddOrUpdate(
|
||||
userId,
|
||||
_ => new HashSet<string> { Context.ConnectionId },
|
||||
(_, set) => { lock (set) { set.Add(Context.ConnectionId); } return set; }
|
||||
);
|
||||
|
||||
_cache.Set("Global_OnlineUsersCount", _userConnections.Count);
|
||||
|
||||
var userChats = await _chatRepository.GetUserChatsAsync(_userContext.UserId, Context.ConnectionAborted);
|
||||
foreach (var chat in userChats)
|
||||
{
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, chat.Id.ToString());
|
||||
}
|
||||
|
||||
_logger.LogInformation("User {UserId} connected with {ConnectionId}, added to {ChatCount} chats",
|
||||
|
||||
userId, Context.ConnectionId, userChats.Count);
|
||||
|
||||
await Clients.Others.SendAsync("user_online", new { userId });
|
||||
}
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
|
||||
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
if (_userContext.IsAuthenticated)
|
||||
{
|
||||
var userId = _userContext.UserId.ToString();
|
||||
if (_userConnections.TryGetValue(userId, out var set))
|
||||
{
|
||||
lock (set) { set.Remove(Context.ConnectionId); }
|
||||
if (set.Count == 0)
|
||||
{
|
||||
_userConnections.TryRemove(userId, out _);
|
||||
await Clients.Others.SendAsync("user_offline", new { userId, lastSeen = DateTime.UtcNow });
|
||||
}
|
||||
}
|
||||
_cache.Set("Global_OnlineUsersCount", _userConnections.Count);
|
||||
_logger.LogInformation("User {UserId} disconnected", userId);
|
||||
}
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Chat methods
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
[HubMethodName("send_message")]
|
||||
public async Task SendMessage(SendMessageHubRequest request)
|
||||
{
|
||||
var attachments = request.Attachments?.Select(a =>
|
||||
|
||||
new AttachmentRequest(a.Type, a.Url, a.FileName, a.FileSize)).ToList();
|
||||
|
||||
var command = new SendMessageCommand(
|
||||
request.ChatId,
|
||||
_userContext.UserId,
|
||||
request.Content,
|
||||
request.Type,
|
||||
attachments,
|
||||
request.ReplyToId,
|
||||
request.Quote,
|
||||
request.ForwardedFromId);
|
||||
|
||||
await _sender.Send(command);
|
||||
}
|
||||
|
||||
[HubMethodName("read_messages")]
|
||||
public async Task ReadMessages(ReadMessagesRequest request)
|
||||
{
|
||||
if (request.LastReadMessageId != Guid.Empty && request.LastReadSequenceId > 0)
|
||||
{
|
||||
var command = new ReadMessagesCommand(
|
||||
request.ChatId, _userContext.UserId, request.LastReadMessageId, request.LastReadSequenceId);
|
||||
await _sender.Send(command);
|
||||
}
|
||||
|
||||
await Clients.Group(request.ChatId.ToString()).SendAsync("messages_read", new
|
||||
{
|
||||
ChatId = request.ChatId.ToString(),
|
||||
UserId = _userContext.UserId,
|
||||
LastReadMessageId = request.LastReadMessageId,
|
||||
LastReadSequenceId = request.LastReadSequenceId
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("delete_messages")]
|
||||
public async Task DeleteMessages(DeleteMessagesHubRequest request)
|
||||
{
|
||||
var parsedIds = request.MessageIds
|
||||
.Select(id => Guid.TryParse(id, out var parsed) ? parsed : Guid.Empty)
|
||||
.Where(id => id != Guid.Empty)
|
||||
.ToList();
|
||||
|
||||
if (parsedIds.Any())
|
||||
{
|
||||
var command = new DeleteMessagesCommand(
|
||||
request.ChatId, _userContext.UserId, parsedIds, request.DeleteForAll);
|
||||
await _sender.Send(command);
|
||||
}
|
||||
}
|
||||
|
||||
[HubMethodName("typing_start")]
|
||||
public async Task TypingStart(string chatId)
|
||||
{
|
||||
await Clients.Group(chatId).SendAsync("user_typing", new { ChatId = chatId, UserId = _userContext.UserId });
|
||||
}
|
||||
|
||||
[HubMethodName("typing_stop")]
|
||||
public async Task TypingStop(string chatId)
|
||||
{
|
||||
await Clients.Group(chatId).SendAsync("user_stopped_typing", new { ChatId = chatId, UserId = _userContext.UserId });
|
||||
}
|
||||
|
||||
[HubMethodName("join_chat")]
|
||||
public async Task JoinChat(string chatId)
|
||||
{
|
||||
// Simple security check: check if user is member of chat (optional but recommended)
|
||||
if (Guid.TryParse(chatId, out var chatGuid))
|
||||
{
|
||||
var userChats = await _chatRepository.GetUserChatsAsync(_userContext.UserId, Context.ConnectionAborted);
|
||||
if (userChats.Any(c => c.Id == chatGuid))
|
||||
{
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, chatId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HubMethodName("add_reaction")]
|
||||
public async Task AddReaction(AddReactionRequest request)
|
||||
{
|
||||
_logger.LogInformation("AddReaction called: MessageId={MessageId}, ChatId={ChatId}, Emoji={Emoji}, UserId={UserId}",
|
||||
|
||||
request.MessageId, request.ChatId, request.Emoji, _userContext.UserId);
|
||||
|
||||
|
||||
var command = new AddReactionCommand(
|
||||
request.MessageId, _userContext.UserId, request.Emoji, request.ChatId);
|
||||
var result = await _sender.Send(command);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
_logger.LogWarning("AddReaction failed: {Error}", result.Error.Description);
|
||||
throw new HubException(result.Error.Description);
|
||||
}
|
||||
|
||||
|
||||
_logger.LogInformation("AddReaction completed successfully");
|
||||
}
|
||||
|
||||
[HubMethodName("remove_reaction")]
|
||||
public async Task RemoveReaction(RemoveReactionRequest request)
|
||||
{
|
||||
_logger.LogInformation("RemoveReaction called: MessageId={MessageId}, ChatId={ChatId}, Emoji={Emoji}, UserId={UserId}",
|
||||
|
||||
request.MessageId, request.ChatId, request.Emoji, _userContext.UserId);
|
||||
|
||||
|
||||
var command = new RemoveReactionCommand(
|
||||
request.MessageId, _userContext.UserId, request.Emoji, request.ChatId);
|
||||
var result = await _sender.Send(command);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
_logger.LogWarning("RemoveReaction failed: {Error}", result.Error.Description);
|
||||
throw new HubException(result.Error.Description);
|
||||
}
|
||||
|
||||
|
||||
_logger.LogInformation("RemoveReaction completed successfully");
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Friend signals (Proxy methods for real-time notification)
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
[HubMethodName("friend_request")]
|
||||
public async Task FriendRequest(FriendSignalRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.FriendId, "friend_request_received", new { userId = _userContext.UserId });
|
||||
}
|
||||
|
||||
[HubMethodName("friend_accepted")]
|
||||
public async Task FriendAccepted(FriendSignalRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.FriendId, "friend_request_accepted", new { userId = _userContext.UserId });
|
||||
}
|
||||
|
||||
[HubMethodName("friend_removed")]
|
||||
public async Task FriendRemoved(FriendSignalRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.FriendId, "friend_removed_notify", new { userId = _userContext.UserId });
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// WebRTC signaling
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task SendToUserAsync(string targetUserId, string method, object payload)
|
||||
{
|
||||
if (_userConnections.TryGetValue(targetUserId, out var connectionIds))
|
||||
{
|
||||
string[] ids;
|
||||
lock (connectionIds) { ids = connectionIds.ToArray(); }
|
||||
foreach (var connId in ids)
|
||||
{
|
||||
await Clients.Client(connId).SendAsync(method, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HubMethodName("call_offer")]
|
||||
public async Task CallOffer(CallOfferRequest request)
|
||||
{
|
||||
// Try to get caller info from current user's claims
|
||||
var displayName = Context.User?.FindFirstValue("name") ?? Context.User?.FindFirstValue(ClaimTypes.Name) ?? "User";
|
||||
var avatar = Context.User?.FindFirstValue("avatar");
|
||||
var username = Context.User?.FindFirstValue("unique_name") ?? Context.User?.Identity?.Name;
|
||||
|
||||
await SendToUserAsync(request.TargetUserId, "call_incoming", new
|
||||
{
|
||||
from = _userContext.UserId.ToString(),
|
||||
offer = request.Offer,
|
||||
callType = request.CallType,
|
||||
chatId = request.ChatId,
|
||||
callerInfo = new
|
||||
{
|
||||
|
||||
id = _userContext.UserId.ToString(),
|
||||
displayName = displayName,
|
||||
avatar = avatar,
|
||||
username = username
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("call_answer")]
|
||||
public async Task CallAnswer(CallAnswerRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.TargetUserId, "call_answered", new
|
||||
{
|
||||
from = _userContext.UserId.ToString(),
|
||||
answer = request.Answer,
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("call_decline")]
|
||||
public async Task CallDecline(TargetUserRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.TargetUserId, "call_declined", new
|
||||
{
|
||||
from = _userContext.UserId.ToString(),
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("call_end")]
|
||||
public async Task CallEnd(TargetUserRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.TargetUserId, "call_ended", new
|
||||
{
|
||||
from = _userContext.UserId.ToString(),
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("ice_candidate")]
|
||||
public async Task IceCandidate(IceCandidateRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.TargetUserId, "ice_candidate", new
|
||||
{
|
||||
from = _userContext.UserId.ToString(),
|
||||
candidate = request.Candidate,
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("renegotiate")]
|
||||
public async Task Renegotiate(RenegotiateRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.TargetUserId, "renegotiate", new
|
||||
{
|
||||
from = _userContext.UserId.ToString(),
|
||||
offer = request.Offer,
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("renegotiate_answer")]
|
||||
public async Task RenegotiateAnswer(RenegotiateAnswerRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.TargetUserId, "renegotiate_answer", new
|
||||
{
|
||||
from = _userContext.UserId.ToString(),
|
||||
answer = request.Answer,
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("call_type_changed")]
|
||||
public async Task CallTypeChanged(CallTypeChangedRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.TargetUserId, "call_type_changed", new
|
||||
{
|
||||
from = _userContext.UserId.ToString(),
|
||||
callType = request.CallType,
|
||||
isScreenSharing = request.IsScreenSharing
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("call_status")]
|
||||
public async Task CallStatus(CallStatusRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.TargetUserId, "call_status_updated", new
|
||||
{
|
||||
from = _userContext.UserId.ToString(),
|
||||
isMuted = request.IsMuted,
|
||||
isVideoOff = request.IsVideoOff
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Group Call signals
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
[HubMethodName("group_call_join")]
|
||||
public async Task GroupCallJoin(GroupCallJoinRequest request)
|
||||
{
|
||||
var chatId = request.ChatId;
|
||||
var userId = _userContext.UserId.ToString();
|
||||
|
||||
|
||||
var displayName = Context.User?.FindFirstValue("name") ?? Context.User?.FindFirstValue(ClaimTypes.Name) ?? "User";
|
||||
var avatar = Context.User?.FindFirstValue("avatar");
|
||||
var username = Context.User?.FindFirstValue("unique_name") ?? Context.User?.Identity?.Name ?? "user";
|
||||
|
||||
var userInfo = new ParticipantInfo(userId, username, displayName, avatar);
|
||||
|
||||
var participants = _groupCallParticipants.GetOrAdd(chatId, _ => new ConcurrentDictionary<string, ParticipantInfo>());
|
||||
var isFirst = participants.IsEmpty;
|
||||
participants.TryAdd(userId, userInfo);
|
||||
|
||||
// Notify others
|
||||
await Clients.Group(chatId).SendAsync("group_call_user_joined", new
|
||||
{
|
||||
chatId = chatId,
|
||||
userId = userId,
|
||||
userInfo = userInfo
|
||||
});
|
||||
|
||||
// Send current participants to joiner (excluding self)
|
||||
var others = participants.Values.Where(p => p.Id != userId).ToList();
|
||||
await Clients.Caller.SendAsync("group_call_participants", new
|
||||
{
|
||||
chatId = chatId,
|
||||
participants = others
|
||||
});
|
||||
|
||||
// Broadcast active call participants to everyone in the chat
|
||||
await Clients.Group(chatId).SendAsync("group_call_active", new
|
||||
{
|
||||
chatId = chatId,
|
||||
participants = participants.Keys.ToList()
|
||||
});
|
||||
|
||||
if (isFirst)
|
||||
{
|
||||
await Clients.Group(chatId).SendAsync("group_call_incoming", new
|
||||
{
|
||||
chatId = chatId,
|
||||
from = userId,
|
||||
callerInfo = userInfo,
|
||||
callType = request.CallType
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[HubMethodName("group_call_leave")]
|
||||
public async Task GroupCallLeave(GroupLeaveRequest request)
|
||||
{
|
||||
var chatId = request.ChatId;
|
||||
var userId = _userContext.UserId.ToString();
|
||||
if (_groupCallParticipants.TryGetValue(chatId, out var participants))
|
||||
{
|
||||
participants.TryRemove(userId, out _);
|
||||
|
||||
// Broadcast updated participants list
|
||||
await Clients.Group(chatId).SendAsync("group_call_active", new
|
||||
{
|
||||
chatId = chatId,
|
||||
participants = participants.Keys.ToList()
|
||||
});
|
||||
|
||||
if (participants.IsEmpty)
|
||||
{
|
||||
_groupCallParticipants.TryRemove(chatId, out _);
|
||||
await Clients.Group(chatId).SendAsync("group_call_ended", new { chatId = chatId });
|
||||
}
|
||||
}
|
||||
|
||||
await Clients.Group(chatId).SendAsync("group_call_user_left", new
|
||||
{
|
||||
chatId = chatId,
|
||||
userId = userId
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("group_call_offer")]
|
||||
public async Task GroupCallOffer(GroupCallOfferRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.TargetUserId, "group_call_offer", new
|
||||
{
|
||||
chatId = request.ChatId,
|
||||
from = _userContext.UserId.ToString(),
|
||||
offer = request.Offer
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("group_call_answer")]
|
||||
public async Task GroupCallAnswer(GroupCallAnswerRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.TargetUserId, "group_call_answer", new
|
||||
{
|
||||
chatId = request.ChatId,
|
||||
from = _userContext.UserId.ToString(),
|
||||
answer = request.Answer
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("group_ice_candidate")]
|
||||
public async Task GroupIceCandidate(GroupIceCandidateRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.TargetUserId, "group_ice_candidate", new
|
||||
{
|
||||
chatId = request.ChatId,
|
||||
from = _userContext.UserId.ToString(),
|
||||
candidate = request.Candidate
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("group_call_renegotiate")]
|
||||
public async Task GroupRenegotiate(GroupRenegotiateRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.TargetUserId, "group_call_renegotiate", new
|
||||
{
|
||||
chatId = request.ChatId,
|
||||
from = _userContext.UserId.ToString(),
|
||||
offer = request.Offer
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("group_call_renegotiate_answer")]
|
||||
public async Task GroupRenegotiateAnswer(GroupRenegotiateAnswerRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.TargetUserId, "group_call_renegotiate_answer", new
|
||||
{
|
||||
chatId = request.ChatId,
|
||||
from = _userContext.UserId.ToString(),
|
||||
answer = request.Answer
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("group_call_status")]
|
||||
public async Task GroupCallStatus(GroupCallStatusRequest request)
|
||||
{
|
||||
var chatId = request.ChatId;
|
||||
var userId = _userContext.UserId.ToString();
|
||||
|
||||
if (_groupCallParticipants.TryGetValue(chatId, out var participants))
|
||||
{
|
||||
if (participants.TryGetValue(userId, out var info))
|
||||
{
|
||||
// Update local state if needed (e.g. muted status)
|
||||
participants[userId] = info with
|
||||
{
|
||||
IsMuted = request.IsMuted,
|
||||
IsVideoOff = request.IsVideoOff
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await Clients.Group(request.ChatId).SendAsync("group_call_status_updated", new
|
||||
{
|
||||
chatId = request.ChatId,
|
||||
userId = Context.UserIdentifier,
|
||||
isMuted = request.IsMuted,
|
||||
isVideoOff = request.IsVideoOff
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("group_call_status_params")]
|
||||
public async Task GroupCallStatusParams(string chatId, bool isMuted, bool isVideoOff)
|
||||
{
|
||||
var userId = _userContext.UserId.ToString();
|
||||
|
||||
if (_groupCallParticipants.TryGetValue(chatId, out var participants))
|
||||
{
|
||||
if (participants.TryGetValue(userId, out var info))
|
||||
{
|
||||
// Update local state if needed (e.g. muted status)
|
||||
participants[userId] = info with
|
||||
{
|
||||
IsMuted = isMuted,
|
||||
IsVideoOff = isVideoOff
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await Clients.Group(chatId).SendAsync("group_call_status_updated", new
|
||||
{
|
||||
chatId = chatId,
|
||||
userId = Context.UserIdentifier,
|
||||
isMuted = isMuted,
|
||||
isVideoOff = isVideoOff
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("get_group_call_status")]
|
||||
public async Task GetGroupCallStatus(GetGroupCallStatusRequest request)
|
||||
{
|
||||
var chatId = request.ChatId;
|
||||
if (_groupCallParticipants.TryGetValue(chatId, out var participants))
|
||||
{
|
||||
var others = participants.Values.ToList();
|
||||
_logger.LogInformation("Found {Count} participants for chat {ChatId}", others.Count, chatId);
|
||||
await Clients.Caller.SendAsync("group_call_active", new
|
||||
{
|
||||
chatId = chatId,
|
||||
participants = others.Select(p => p.Id).ToList()
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("No active call for chat {ChatId}", chatId);
|
||||
await Clients.Caller.SendAsync("group_call_active", new
|
||||
{
|
||||
chatId = chatId,
|
||||
participants = new List<string>()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[HubMethodName("screen_share_started")]
|
||||
public async Task ScreenShareStarted(string chatId)
|
||||
{
|
||||
var userId = _userContext.UserId.ToString();
|
||||
if (_groupCallParticipants.TryGetValue(chatId, out var participants))
|
||||
{
|
||||
if (participants.TryGetValue(userId, out var info))
|
||||
{
|
||||
participants[userId] = info with { IsSharingScreen = true };
|
||||
}
|
||||
}
|
||||
|
||||
await Clients.Group(chatId).SendAsync("screen_share_started", new
|
||||
{
|
||||
chatId = chatId,
|
||||
userId = userId
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("screen_share_stopped")]
|
||||
public async Task ScreenShareStopped(string chatId)
|
||||
{
|
||||
var userId = _userContext.UserId.ToString();
|
||||
if (_groupCallParticipants.TryGetValue(chatId, out var participants))
|
||||
{
|
||||
if (participants.TryGetValue(userId, out var info))
|
||||
{
|
||||
participants[userId] = info with { IsSharingScreen = false };
|
||||
}
|
||||
}
|
||||
|
||||
await Clients.Group(chatId).SendAsync("screen_share_stopped", new
|
||||
{
|
||||
chatId = chatId,
|
||||
userId = userId
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Records
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
public record AttachmentHubRequest(string Type, string Url, string? FileName, long? FileSize);
|
||||
public record SendMessageHubRequest(
|
||||
Guid ChatId,
|
||||
|
||||
string? Content,
|
||||
|
||||
string Type,
|
||||
|
||||
List<AttachmentHubRequest>? Attachments = null,
|
||||
Guid? ReplyToId = null,
|
||||
string? Quote = null,
|
||||
Guid? ForwardedFromId = null);
|
||||
public record ReadMessagesRequest(Guid ChatId, Guid LastReadMessageId, long LastReadSequenceId);
|
||||
public record CallOfferRequest(string TargetUserId, object Offer, string CallType, string? ChatId);
|
||||
public record CallAnswerRequest(string TargetUserId, object Answer);
|
||||
public record TargetUserRequest(string TargetUserId);
|
||||
public record IceCandidateRequest(string TargetUserId, object Candidate);
|
||||
public record RenegotiateRequest(string TargetUserId, object Offer);
|
||||
public record RenegotiateAnswerRequest(string TargetUserId, object Answer);
|
||||
public record CallTypeChangedRequest(string TargetUserId, string CallType, bool IsScreenSharing = false);
|
||||
public record CallStatusRequest(string TargetUserId, bool IsMuted, bool IsVideoOff);
|
||||
public record AddReactionRequest(Guid MessageId, Guid ChatId, string Emoji);
|
||||
public record RemoveReactionRequest(Guid MessageId, Guid ChatId, string Emoji);
|
||||
public record DeleteMessagesHubRequest(Guid ChatId, List<string> MessageIds, bool DeleteForAll);
|
||||
public record GroupCallJoinRequest(string ChatId, string CallType);
|
||||
public record ParticipantInfo(string Id, string Username, string DisplayName, string? Avatar, bool IsSharingScreen = false, bool IsMuted = false, bool IsVideoOff = false);
|
||||
public record GroupLeaveRequest(string ChatId);
|
||||
public record GetGroupCallStatusRequest(string ChatId);
|
||||
public record GroupCallStatusRequest(string ChatId, bool IsMuted, bool IsVideoOff);
|
||||
public record GroupCallOfferRequest(string ChatId, string TargetUserId, object Offer);
|
||||
public record GroupCallAnswerRequest(string ChatId, string TargetUserId, object Answer);
|
||||
public record GroupIceCandidateRequest(string ChatId, string TargetUserId, object Candidate);
|
||||
public record GroupRenegotiateRequest(string ChatId, string TargetUserId, object Offer);
|
||||
public record GroupRenegotiateAnswerRequest(string ChatId, string TargetUserId, object Answer);
|
||||
public record FriendSignalRequest(string FriendId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||
using Knot.Modules.Messaging.Application.Abstractions;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
public class MessageNotifier : IMessageNotifier { private readonly IHubContext<ChatHub> _hubContext; public MessageNotifier(IHubContext<ChatHub> hubContext) { _hubContext = hubContext; } public Task NotifyNewMessageAsync(Guid chatId, object messagePayload, CancellationToken cancellationToken) { return _hubContext.Clients.Group(chatId.ToString()).SendAsync("new_message", messagePayload, cancellationToken); } }
|
||||
Reference in New Issue
Block a user