Reorganize root folder structure: Remove apps layer layer

This commit is contained in:
Халимов Рустам
2026-03-19 22:22:45 +03:00
parent fb252f9d87
commit 11ebbc853b
296 changed files with 139 additions and 139 deletions

View File

@@ -0,0 +1,10 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Chats.Application.Abstractions;
/// <summary>
/// Unit of Work специфичный для модуля Chats.
/// </summary>
public interface IChatsUnitOfWork : IUnitOfWork
{
}

View File

@@ -0,0 +1,129 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Abstractions;
using MediatR;
using System.Linq;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
namespace Knot.Modules.Chats.Application.Chats.Avatar;
public record UploadGroupAvatarCommand(Guid ChatId, Guid UserId, string FileName, string ContentType, Stream FileStream) : ICommand<Guid>;
internal sealed class UploadGroupAvatarCommandHandler : ICommandHandler<UploadGroupAvatarCommand, Guid>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _uow;
private readonly IFileStorageService _fileStorage;
public UploadGroupAvatarCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow, IFileStorageService fileStorage)
{
_chatRepository = chatRepository;
_uow = uow;
_fileStorage = fileStorage;
}
public async Task<Result<Guid>> Handle(UploadGroupAvatarCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
}
var fileId = await _fileStorage.UploadFileAsync(request.FileStream, request.FileName, request.ContentType);
var url = $"/api/files/{fileId}";
chat.UpdateAvatar(url);
_chatRepository.Update(chat);
await _uow.SaveChangesAsync(cancellationToken);
return Result.Success(request.ChatId);
}
}
public record CropGroupAvatarCommand(Guid ChatId, Guid UserId, string FileName, string ContentType, Stream FileStream, int X, int Y, int Width, int Height) : ICommand<Guid>;
internal sealed class CropGroupAvatarCommandHandler : ICommandHandler<CropGroupAvatarCommand, Guid>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _uow;
private readonly IFileStorageService _fileStorage;
public CropGroupAvatarCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow, IFileStorageService fileStorage)
{
_chatRepository = chatRepository;
_uow = uow;
_fileStorage = fileStorage;
}
public async Task<Result<Guid>> Handle(CropGroupAvatarCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
}
string url;
using (var image = await SixLabors.ImageSharp.Image.LoadAsync(request.FileStream))
{
int startX = Math.Max(0, Math.Min(request.X, image.Width - 1));
int startY = Math.Max(0, Math.Min(request.Y, image.Height - 1));
int rectWidth = Math.Max(1, Math.Min(request.Width, image.Width - startX));
int rectHeight = Math.Max(1, Math.Min(request.Height, image.Height - startY));
image.Mutate(ctx => ctx.Crop(new SixLabors.ImageSharp.Rectangle(startX, startY, rectWidth, rectHeight)));
image.Mutate(ctx => ctx.Resize(400, 400));
using var outStream = new MemoryStream();
await image.SaveAsJpegAsync(outStream, cancellationToken);
outStream.Position = 0;
var fileName = request.FileName ?? "avatar.jpg";
var fileId = await _fileStorage.UploadFileAsync(outStream, fileName, "image/jpeg");
url = $"/api/files/{fileId}";
}
chat.UpdateAvatar(url);
_chatRepository.Update(chat);
await _uow.SaveChangesAsync(cancellationToken);
return Result.Success(request.ChatId);
}
}
public record RemoveGroupAvatarCommand(Guid ChatId, Guid UserId) : ICommand<Guid>;
internal sealed class RemoveGroupAvatarCommandHandler : ICommandHandler<RemoveGroupAvatarCommand, Guid>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _uow;
public RemoveGroupAvatarCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
{
_chatRepository = chatRepository;
_uow = uow;
}
public async Task<Result<Guid>> Handle(RemoveGroupAvatarCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
}
chat.UpdateAvatar(null);
_chatRepository.Update(chat);
await _uow.SaveChangesAsync(cancellationToken);
return Result.Success(request.ChatId);
}
}

View File

@@ -0,0 +1,34 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Abstractions;
using MediatR;
using System.Linq;
namespace Knot.Modules.Chats.Application.Chats.Clear;
public record ClearChatCommand(Guid ChatId, Guid UserId) : ICommand<MessageResponse>;
internal sealed class ClearChatCommandHandler : ICommandHandler<ClearChatCommand, MessageResponse>
{
private readonly IChatRepository _chatRepository;
public ClearChatCommandHandler(IChatRepository chatRepository)
{
_chatRepository = chatRepository;
}
public async Task<Result<MessageResponse>> Handle(ClearChatCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<MessageResponse>(ChatErrors.ChatNotFound);
}
// Currently a placeholder
return Result.Success(new MessageResponse("Cleared"));
}
}

View File

@@ -0,0 +1,43 @@
using Knot.Modules.Chats.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.Abstractions;
namespace Knot.Modules.Chats.Application.Chats.Create;
/// <summary>
/// Команда для создания чата.
/// </summary>
public sealed record CreateChatCommand(
string Name,
ChatType Type,
List<Guid> MemberIds) : ICommand<Guid>;
public sealed class CreateChatCommandHandler : ICommandHandler<CreateChatCommand, Guid>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _unitOfWork;
public CreateChatCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork)
{
_chatRepository = chatRepository;
_unitOfWork = unitOfWork;
}
public async Task<Result<Guid>> Handle(CreateChatCommand request, CancellationToken cancellationToken)
{
var chat = Chat.Create(request.Name, request.Type);
for (int i = 0; i < request.MemberIds.Count; i++)
{
var userId = request.MemberIds[i];
var role = (i == 0) ? ChatRole.Owner : ChatRole.Member;
chat.AddMember(userId, role);
}
_chatRepository.Add(chat);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success(chat.Id);
}
}

View File

@@ -0,0 +1,140 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.DTOs;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Abstractions;
namespace Knot.Modules.Chats.Application.Chats.GetChatById;
public record GetChatByIdQuery(Guid UserId, Guid ChatId) : IQuery<ChatDto?>;
internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery, ChatDto?>
{
private readonly IChatRepository _chatRepository;
private readonly IUserDisplayNameProvider _userProvider;
private readonly IMessageRepository _messageRepository;
public GetChatByIdQueryHandler(IChatRepository chatRepository, IUserDisplayNameProvider userProvider, IMessageRepository messageRepository)
{
_chatRepository = chatRepository;
_userProvider = userProvider;
_messageRepository = messageRepository;
}
public async Task<Result<ChatDto?>> Handle(GetChatByIdQuery request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null)
{
return Result.Success<ChatDto?>(null);
}
if (!chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<ChatDto?>(ChatErrors.ChatsForbidden);
}
var userIdsToFetch = new HashSet<Guid>();
foreach (var member in chat.Members)
{
userIdsToFetch.Add(member.UserId);
}
var latestMessage = await _messageRepository.GetLatestChatMessageAsync(chat.Id, cancellationToken);
if (latestMessage != null)
{
userIdsToFetch.Add(latestMessage.SenderId);
foreach (var reaction in latestMessage.Reactions)
{
userIdsToFetch.Add(reaction.UserId);
}
}
var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
var members = new List<ChatMemberDto>();
foreach (var member in chat.Members)
{
usersInfo.TryGetValue(member.UserId, out var user);
members.Add(new ChatMemberDto(
member.Id,
member.UserId,
member.Role,
member.IsPinned,
user != null ? new ChatUserDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
false,
DateTime.UtcNow
) : null
));
}
var messagesList = new List<ChatMessageDto>();
if (latestMessage != null)
{
usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
var reactionsWithUser = new List<ReactionDto>();
foreach (var reaction in latestMessage.Reactions)
{
usersInfo.TryGetValue(reaction.UserId, out var reactionUser);
reactionsWithUser.Add(new ReactionDto(
reaction.Id,
reaction.Emoji,
reaction.UserId,
reactionUser != null
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null)
));
}
messagesList.Add(new ChatMessageDto(
latestMessage.Id,
latestMessage.ChatId,
latestMessage.SenderId,
latestMessage.Content,
latestMessage.Type,
latestMessage.ReplyToId,
latestMessage.Quote,
latestMessage.StoryId,
latestMessage.StoryMediaUrl,
latestMessage.StoryMediaType,
latestMessage.IsEdited,
latestMessage.IsDeleted,
latestMessage.CreatedAt,
latestMessage.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
senderObj != null ? new MessageSenderDto(
senderObj.Id,
senderObj.Username,
senderObj.DisplayName,
senderObj.Avatar
) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
reactionsWithUser,
latestMessage.ReadBy.Select(readReceipt => new ReadByDto(readReceipt.UserId)).ToList()
));
}
var dto = new ChatDto(
chat.Id,
chat.Type.ToString().ToLowerInvariant(),
chat.Type == ChatType.Favorites ? "Избранное" : (chat.Type == ChatType.Personal ? null : chat.Name),
chat.Description,
chat.Avatar,
chat.CreatedAt,
members,
messagesList,
0
);
return Result.Success<ChatDto?>(dto);
}
}

View File

@@ -0,0 +1,148 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.DTOs;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Abstractions;
namespace Knot.Modules.Chats.Application.Chats.GetChats;
public record GetChatsQuery(Guid UserId) : IQuery<List<ChatDto>>;
internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<ChatDto>>
{
private readonly IChatRepository _chatRepository;
private readonly IUserDisplayNameProvider _userProvider;
private readonly IMessageRepository _messageRepository;
public GetChatsQueryHandler(IChatRepository chatRepository, IUserDisplayNameProvider userProvider, IMessageRepository messageRepository)
{
_chatRepository = chatRepository;
_userProvider = userProvider;
_messageRepository = messageRepository;
}
public async Task<Result<List<ChatDto>>> Handle(GetChatsQuery request, CancellationToken cancellationToken)
{
var userChats = await _chatRepository.GetUserChatsAsync(request.UserId, cancellationToken);
var dtos = new List<ChatDto>();
bool hasFavorites = false;
foreach (var chat in userChats)
{
var latestMessage = await _messageRepository.GetLatestChatMessageAsync(chat.Id, cancellationToken);
if (latestMessage == null)
{
continue;
}
var userIdsToFetch = new HashSet<Guid>();
foreach (var member in chat.Members)
{
userIdsToFetch.Add(member.UserId);
}
userIdsToFetch.Add(latestMessage.SenderId);
foreach (var r in latestMessage.Reactions)
{
userIdsToFetch.Add(r.UserId);
}
var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
var members = new List<ChatMemberDto>();
foreach (var member in chat.Members)
{
usersInfo.TryGetValue(member.UserId, out var user);
members.Add(new ChatMemberDto(
member.Id,
member.UserId,
member.Role,
member.IsPinned,
user != null ? new ChatUserDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
false,
DateTime.UtcNow
) : null
));
}
usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
var reactionsWithUser = new List<ReactionDto>();
foreach (var reaction in latestMessage.Reactions)
{
usersInfo.TryGetValue(reaction.UserId, out var reactionUser);
reactionsWithUser.Add(new ReactionDto(
reaction.Id,
reaction.Emoji,
reaction.UserId,
reactionUser != null
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null)
));
}
var messagesList = new List<ChatMessageDto>
{
new ChatMessageDto(
latestMessage.Id,
latestMessage.ChatId,
latestMessage.SenderId,
latestMessage.Content,
latestMessage.Type,
latestMessage.ReplyToId,
latestMessage.Quote,
latestMessage.StoryId,
latestMessage.StoryMediaUrl,
latestMessage.StoryMediaType,
latestMessage.IsEdited,
latestMessage.IsDeleted,
latestMessage.CreatedAt,
latestMessage.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
senderObj != null ? new MessageSenderDto(
senderObj.Id,
senderObj.Username,
senderObj.DisplayName,
senderObj.Avatar
) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
reactionsWithUser,
latestMessage.ReadBy.Select(readReceipt => new ReadByDto(readReceipt.UserId)).ToList()
)
};
var unreadCount = await _messageRepository.GetUnreadCountAsync(chat.Id, request.UserId, cancellationToken);
dtos.Add(new ChatDto(
chat.Id,
chat.Type.ToString().ToLowerInvariant(),
chat.Type == ChatType.Favorites ? "Избранное" : (chat.Type == ChatType.Personal ? null : chat.Name),
chat.Description,
chat.Avatar,
chat.CreatedAt,
members,
messagesList,
unreadCount
));
}
if (!hasFavorites)
{
var favs = new ChatDto(Guid.Empty, "favorites", "Избранное", null, null, DateTime.UtcNow, new List<ChatMemberDto>(), new List<ChatMessageDto>(), 0);
dtos.Add(favs);
}
var sorted = dtos.OrderByDescending(d => d.Messages.FirstOrDefault()?.CreatedAt ?? d.CreatedAt).ToList();
return Result.Success(sorted);
}
}

View File

@@ -0,0 +1,38 @@
using Knot.Modules.Chats.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.Abstractions;
namespace Knot.Modules.Chats.Application.Chats.GetOrCreateFavorites;
public sealed record GetOrCreateFavoritesCommand(Guid UserId) : ICommand<Guid>;
public sealed class GetOrCreateFavoritesCommandHandler : ICommandHandler<GetOrCreateFavoritesCommand, Guid>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _unitOfWork;
public GetOrCreateFavoritesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork)
{
_chatRepository = chatRepository;
_unitOfWork = unitOfWork;
}
public async Task<Result<Guid>> Handle(GetOrCreateFavoritesCommand request, CancellationToken cancellationToken)
{
var favorites = await _chatRepository.GetFavoritesAsync(request.UserId, cancellationToken);
if (favorites != null)
{
return Result.Success(favorites.Id);
}
// Create new favorites chat
var chat = Chat.Create("Избранное", ChatType.Favorites);
chat.AddMember(request.UserId, ChatRole.Owner);
_chatRepository.Add(chat);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success(chat.Id);
}
}

View File

@@ -0,0 +1,53 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Abstractions;
using MediatR;
using System.Linq;
namespace Knot.Modules.Chats.Application.Chats.LeaveOrDelete;
public record LeaveOrDeleteChatCommand(Guid ChatId, Guid UserId) : ICommand<SuccessResponse>;
internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrDeleteChatCommand, SuccessResponse>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _uow;
public LeaveOrDeleteChatCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
{
_chatRepository = chatRepository;
_uow = uow;
}
public async Task<Result<SuccessResponse>> Handle(LeaveOrDeleteChatCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null)
{
return Result.Success(new SuccessResponse(true));
}
if (!chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<SuccessResponse>(ChatErrors.Unauthorized);
}
if (chat.Type == ChatType.Group)
{
chat.RemoveMember(request.UserId);
_chatRepository.Update(chat);
}
else
{
_chatRepository.Remove(chat);
}
await _uow.SaveChangesAsync(cancellationToken);
return Result.Success(new SuccessResponse(true));
}
}

View File

@@ -0,0 +1,72 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Abstractions;
using MediatR;
using System.Linq;
namespace Knot.Modules.Chats.Application.Chats.Members;
public record AddMembersCommand(Guid ChatId, Guid UserId, List<Guid> UserIdsToAdd) : ICommand<Guid>;
internal sealed class AddMembersCommandHandler : ICommandHandler<AddMembersCommand, Guid>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _uow;
public AddMembersCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
{
_chatRepository = chatRepository;
_uow = uow;
}
public async Task<Result<Guid>> Handle(AddMembersCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
}
foreach (var userId in request.UserIdsToAdd)
{
chat.AddMember(userId);
}
_chatRepository.Update(chat);
await _uow.SaveChangesAsync(cancellationToken);
return Result.Success(request.ChatId);
}
}
public record RemoveMemberCommand(Guid ChatId, Guid UserId, Guid UserIdToRemove) : ICommand<Guid>;
internal sealed class RemoveMemberCommandHandler : ICommandHandler<RemoveMemberCommand, Guid>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _uow;
public RemoveMemberCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
{
_chatRepository = chatRepository;
_uow = uow;
}
public async Task<Result<Guid>> Handle(RemoveMemberCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
}
chat.RemoveMember(request.UserIdToRemove);
_chatRepository.Update(chat);
await _uow.SaveChangesAsync(cancellationToken);
return Result.Success(request.ChatId);
}
}

View File

@@ -0,0 +1,46 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Chats.Application.DTOs;
using MediatR;
using System.Linq;
namespace Knot.Modules.Chats.Application.Chats.TogglePin;
public record TogglePinCommand(Guid ChatId, Guid UserId) : ICommand<TogglePinResponse>;
internal sealed class TogglePinCommandHandler : ICommandHandler<TogglePinCommand, TogglePinResponse>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _uow;
public TogglePinCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
{
_chatRepository = chatRepository;
_uow = uow;
}
public async Task<Result<TogglePinResponse>> Handle(TogglePinCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null)
{
return Result.Failure<TogglePinResponse>(ChatErrors.ChatNotFound);
}
var member = chat.Members.FirstOrDefault(m => m.UserId == request.UserId);
if (member == null)
{
return Result.Failure<TogglePinResponse>(ChatErrors.ChatNotFound);
}
member.TogglePin();
_chatRepository.Update(chat);
await _uow.SaveChangesAsync(cancellationToken);
return Result.Success(new TogglePinResponse(member.IsPinned));
}
}

View File

@@ -0,0 +1,48 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Abstractions;
using MediatR;
using System.Linq;
namespace Knot.Modules.Chats.Application.Chats.Update;
public record UpdateChatCommand(Guid ChatId, Guid UserId, string? Name, string? Description) : ICommand<Guid>;
internal sealed class UpdateChatCommandHandler : ICommandHandler<UpdateChatCommand, Guid>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _uow;
public UpdateChatCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
{
_chatRepository = chatRepository;
_uow = uow;
}
public async Task<Result<Guid>> Handle(UpdateChatCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
}
if (request.Name != null)
{
chat.UpdateName(request.Name);
}
if (request.Description != null)
{
chat.UpdateDescription(request.Description);
}
_chatRepository.Update(chat);
await _uow.SaveChangesAsync(cancellationToken);
return Result.Success(chat.Id);
}
}

View File

@@ -0,0 +1,6 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Chats.Application.DTOs;
public sealed record AddMembersRequest(List<Guid> UserIds);

View File

@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Chats.Application.DTOs;
public record ChatDto(
Guid Id,
string Type,
string? Name,
string? Description,
string? Avatar,
DateTime CreatedAt,
List<ChatMemberDto> Members,
List<ChatMessageDto> Messages,
int UnreadCount
);

View File

@@ -0,0 +1,12 @@
using Knot.Modules.Chats.Domain;
using System;
namespace Knot.Modules.Chats.Application.DTOs;
public record ChatMemberDto(
Guid Id,
Guid UserId,
string Role,
bool IsPinned,
ChatUserDto? User
);

View File

@@ -0,0 +1,24 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Chats.Application.DTOs;
public record ChatMessageDto(
Guid Id,
Guid ChatId,
Guid SenderId,
string? Content,
string Type,
Guid? ReplyToId,
string? Quote,
Guid? StoryId,
string? StoryMediaUrl,
string? StoryMediaType,
bool IsEdited,
bool IsDeleted,
DateTime CreatedAt,
List<MediaDto> Media,
MessageSenderDto Sender,
List<ReactionDto> Reactions,
List<ReadByDto> ReadBy
);

View File

@@ -0,0 +1,12 @@
using System;
namespace Knot.Modules.Chats.Application.DTOs;
public record ChatUserDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar,
bool IsOnline,
DateTime LastSeen
);

View File

@@ -0,0 +1,7 @@
using System;
using System.Collections.Generic;
using Knot.Modules.Chats.Domain;
namespace Knot.Modules.Chats.Application.DTOs;
public sealed record CreateChatRequest(string Name, ChatType Type, List<Guid> MemberIds);

View File

@@ -0,0 +1,6 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Chats.Application.DTOs;
public sealed record CreateGroupChatRequest(string Name, List<Guid> MemberIds);

View File

@@ -0,0 +1,5 @@
using System;
namespace Knot.Modules.Chats.Application.DTOs;
public sealed record CreatePersonalChatRequest(Guid UserId);

View File

@@ -0,0 +1,11 @@
using System;
namespace Knot.Modules.Chats.Application.DTOs;
public record MediaDto(
Guid Id,
string Type,
string Url,
string? Filename,
long? Size
);

View File

@@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Chats.Application.DTOs;
public record MessageDetailDto(
Guid Id,
Guid ChatId,
Guid SenderId,
string? Content,
string Type,
Guid? ReplyToId,
ReplyToMessageDto? ReplyTo,
string? Quote,
bool IsEdited,
bool IsDeleted,
DateTime CreatedAt,
Guid? ForwardedFromId,
MessageSenderDto? ForwardedFrom,
Guid? StoryId,
string? StoryMediaUrl,
string? StoryMediaType,
List<MediaDto> Media,
MessageSenderDto? Sender,
List<ReadByDto> ReadBy,
List<MessageReactionDto> Reactions
);
public record ReplyToMessageDto(
Guid Id,
string? Content,
bool IsDeleted,
List<MediaDto> Media,
MessageSenderDto? Sender
);
public record MessageReactionDto(
Guid Id,
string Emoji,
Guid UserId,
MessageSenderDto? User
);

View File

@@ -0,0 +1,10 @@
using System;
namespace Knot.Modules.Chats.Application.DTOs;
public record MessageSenderDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar
);

View File

@@ -0,0 +1,10 @@
using System;
namespace Knot.Modules.Chats.Application.DTOs;
public record ReactionDto(
Guid Id,
string Emoji,
Guid UserId,
MessageSenderDto User
);

View File

@@ -0,0 +1,7 @@
using System;
namespace Knot.Modules.Chats.Application.DTOs;
public record ReadByDto(
Guid UserId
);

View File

@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Chats.Application.DTOs;
public record SearchMessageDto(
Guid Id,
Guid ChatId,
Guid SenderId,
string? Content,
string Type,
Guid? ReplyToId,
string? Quote,
bool IsEdited,
bool IsDeleted,
DateTime CreatedAt,
Guid? ForwardedFromId,
MessageSenderDto? ForwardedFrom,
Guid? StoryId,
string? StoryMediaUrl,
string? StoryMediaType,
List<MediaDto> Media,
MessageSenderDto Sender,
List<SimpleReactionDto> Reactions,
List<ReadByDto> ReadBy
);
public record SimpleReactionDto(
Guid UserId,
string Emoji
);

View File

@@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Chats.Application.DTOs;
public sealed record SendMessageRequest(
string? Content,
string Type,
List<AttachmentDto>? Attachments = null,
Guid? ReplyToId = null,
string? Quote = null,
Guid? ForwardedFromId = null);
public sealed record AttachmentDto(string Type, string Url, string? FileName, long? FileSize);

View File

@@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Chats.Application.DTOs;
public record SharedMediaDto(
Guid Id,
string? Content,
DateTime CreatedAt,
List<string>? Links,
MessageSenderDto? Sender,
Guid? ReplyToId,
string? Quote,
Guid? StoryId,
string? StoryMediaUrl,
string? StoryMediaType,
bool? IsEdited,
string? Type,
List<MediaDto>? Media
);

View File

@@ -0,0 +1,3 @@
namespace Knot.Modules.Chats.Application.DTOs;
public record TogglePinResponse(bool IsPinned);

View File

@@ -0,0 +1,3 @@
namespace Knot.Modules.Chats.Application.DTOs;
public sealed record UpdateChatRequest(string? Name, string? Description);

View File

@@ -0,0 +1,10 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Chats.Application.DTOs;
public record UploadFileResponseDto(
string Url,
string Filename,
long Size
);

View File

@@ -0,0 +1,79 @@
using MediatR;
using Microsoft.AspNetCore.SignalR;
using global::Knot.Modules.Chats.Domain;
using global::Knot.Modules.Chats.Infrastructure.SignalR;
using global::Knot.Shared.Kernel;
using global::Knot.Modules.Chats.Application.Abstractions;
namespace Knot.Modules.Chats.Application.Messages.Delete;
public sealed record DeleteMessagesCommand(
Guid ChatId,
Guid UserId,
List<Guid> MessageIds,
bool DeleteForAll) : ICommand;
public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessagesCommand>
{
private readonly IMessageRepository _messageRepository;
private readonly IChatsUnitOfWork _unitOfWork;
private readonly IHubContext<ChatHub> _hubContext;
public DeleteMessagesCommandHandler(
IMessageRepository messageRepository,
IChatsUnitOfWork unitOfWork,
IHubContext<ChatHub> hubContext)
{
_messageRepository = messageRepository;
_unitOfWork = unitOfWork;
_hubContext = hubContext;
}
public async Task<global::Knot.Shared.Kernel.Result> Handle(DeleteMessagesCommand request, CancellationToken cancellationToken)
{
foreach (var id in request.MessageIds)
{
var message = await _messageRepository.GetByIdAsync(id, cancellationToken);
if (message is null || message.ChatId != request.ChatId)
{
continue;
}
if (request.DeleteForAll)
{
if (message.SenderId == request.UserId)
{
message.Delete();
}
}
else
{
message.DeleteForUser(request.UserId);
}
await _messageRepository.UpdateAsync(message, cancellationToken);
}
if (request.DeleteForAll)
{
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("messages_deleted", new
{
chatId = request.ChatId,
messageIds = request.MessageIds,
deleteForAll = true
});
}
else
{
// Уведомляем только самого пользователя (все его текущие сессии)
await _hubContext.Clients.User(request.UserId.ToString()).SendAsync("messages_deleted", new
{
chatId = request.ChatId,
messageIds = request.MessageIds,
deleteForAll = false
});
}
return global::Knot.Shared.Kernel.Result.Success();
}
}

View File

@@ -0,0 +1,139 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.DTOs;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Abstractions;
namespace Knot.Modules.Chats.Application.Messages.GetMessages;
public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor) : IQuery<List<MessageDetailDto>>;
internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery, List<MessageDetailDto>>
{
private readonly IMessageRepository _messageRepository;
private readonly IUserDisplayNameProvider _userProvider;
private readonly IChatRepository _chatRepository;
public GetMessagesQueryHandler(IMessageRepository messageRepository, IUserDisplayNameProvider userProvider, IChatRepository chatRepository)
{
_messageRepository = messageRepository;
_userProvider = userProvider;
_chatRepository = chatRepository;
}
public async Task<Result<List<MessageDetailDto>>> Handle(GetMessagesQuery request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<List<MessageDetailDto>>(ChatErrors.ChatsForbidden);
}
DateTime? cursorDate = null;
if (!string.IsNullOrEmpty(request.Cursor) && DateTime.TryParse(request.Cursor, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed))
{
cursorDate = parsed.ToUniversalTime();
}
var messages = await _messageRepository.GetChatMessagesCursorAsync(request.ChatId, cursorDate, ChatConstants.DefaultMessageQueryLimit, cancellationToken);
var result = new List<MessageDetailDto>();
var userIdsToFetch = new HashSet<Guid>();
var replyMessages = new Dictionary<Guid, Message>();
// Filter messages that are deleted for the current user before processing
var filteredMessages = messages.Where(m => !m.IsDeletedForUser(request.UserId)).ToList();
foreach (var m in filteredMessages)
{
userIdsToFetch.Add(m.SenderId);
if (!m.ReplyToId.HasValue)
{
continue;
}
var replyMsg = await _messageRepository.GetByIdAsync(m.ReplyToId.Value, cancellationToken);
if (replyMsg == null)
{
continue;
}
replyMessages[replyMsg.Id] = replyMsg;
userIdsToFetch.Add(replyMsg.SenderId);
}
var senders = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
foreach (var message in messages)
{
if (message.IsDeletedForUser(request.UserId))
{
continue;
}
ReplyToMessageDto? replyToObj = null;
if (message.ReplyToId.HasValue && replyMessages.TryGetValue(message.ReplyToId.Value, out var replyMsg))
{
var senderObj = senders.TryGetValue(replyMsg.SenderId, out var rs)
? new MessageSenderDto(rs.Id, rs.Username, rs.DisplayName, rs.Avatar)
: null;
replyToObj = new ReplyToMessageDto(
replyMsg.Id,
replyMsg.Content,
replyMsg.IsDeleted,
replyMsg.Media.Select(rm => new MediaDto(rm.Id, rm.Type, rm.Url, rm.Filename, rm.Size)).ToList(),
senderObj
);
}
var reactionsWithUser = new List<MessageReactionDto>();
foreach (var reaction in message.Reactions)
{
var userObj = senders.TryGetValue(reaction.UserId, out var reactionUser)
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null);
reactionsWithUser.Add(new MessageReactionDto(
reaction.Id,
reaction.Emoji,
reaction.UserId,
userObj
));
}
result.Add(new MessageDetailDto(
message.Id,
message.ChatId,
message.SenderId,
message.Content,
message.Type,
message.ReplyToId,
replyToObj,
message.Quote,
message.IsEdited,
message.IsDeleted,
message.CreatedAt,
message.ForwardedFromId,
message.ForwardedFromId.HasValue && senders.TryGetValue(message.ForwardedFromId.Value, out var fwdUser) ? new MessageSenderDto(fwdUser.Id, fwdUser.Username, fwdUser.DisplayName, fwdUser.Avatar) : null,
message.StoryId,
message.StoryMediaUrl,
message.StoryMediaType,
message.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : null,
message.ReadBy.Select(readReceipt => new ReadByDto(readReceipt.UserId)).ToList(),
reactionsWithUser
));
}
return Result.Success(result);
}
}

View File

@@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.DTOs;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Abstractions;
namespace Knot.Modules.Chats.Application.Messages.GetSharedMedia;
public record GetSharedMediaQuery(Guid UserId, Guid ChatId, string? Type) : IQuery<List<SharedMediaDto>>;
internal sealed class GetSharedMediaQueryHandler : IQueryHandler<GetSharedMediaQuery, List<SharedMediaDto>>
{
private readonly IMessageRepository _messageRepository;
private readonly IUserDisplayNameProvider _userProvider;
private readonly IChatRepository _chatRepository;
public GetSharedMediaQueryHandler(IMessageRepository messageRepository, IUserDisplayNameProvider userProvider, IChatRepository chatRepository)
{
_messageRepository = messageRepository;
_userProvider = userProvider;
_chatRepository = chatRepository;
}
public async Task<Result<List<SharedMediaDto>>> Handle(GetSharedMediaQuery request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null || !chat.Members.Any(member => member.UserId == request.UserId))
{
return Result.Failure<List<SharedMediaDto>>(ChatErrors.ChatsForbidden);
}
var messages = await _messageRepository.GetChatMessagesAsync(request.ChatId, ChatConstants.MaxSharedMediaQueryLimit, 0, cancellationToken);
messages = messages.Where(message => !message.IsDeletedForUser(request.UserId)).ToList();
var result = new List<SharedMediaDto>();
var filterType = request.Type?.ToLower();
var userIds = messages.Select(message => message.SenderId).Distinct();
var senders = await _userProvider.GetUsersInfoAsync(userIds, cancellationToken);
foreach (var message in messages)
{
if (filterType == "links")
{
var messageContent = message.Content;
var linkRegex = new Regex(@"https?://[^\s]+", RegexOptions.IgnoreCase);
var contentLinks = !string.IsNullOrEmpty(messageContent) ? linkRegex.Matches(messageContent).Select(match => match.Value).ToList() : new List<string>();
var messageMediaColl = message.Media;
var mediaLinks = (messageMediaColl ?? Enumerable.Empty<Media>()).Where(media => media.Type?.ToString().ToLower() == "link").Select(media => media.Url).ToList();
var allLinks = contentLinks.Concat(mediaLinks).Distinct().ToList();
if (allLinks.Any())
{
senders.TryGetValue(message.SenderId, out var sender);
result.Add(new SharedMediaDto(
message.Id,
messageContent,
message.CreatedAt,
allLinks,
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : null,
null, null, null, null, null, null, null, null
));
}
continue;
}
var messageMedia = message.Media;
if (messageMedia == null || !messageMedia.Any())
{
continue;
}
var filteredMedia = messageMedia.Where(media =>
{
var mediaType = media.Type?.ToLower() ?? "file";
var isGif = mediaType == "image" && (media.Url.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase) || media.Url.EndsWith(".gif", StringComparison.OrdinalIgnoreCase));
if (filterType == "gifs")
{
return isGif;
}
if (filterType == "files")
{
return mediaType != "image" && mediaType != "video" && mediaType != "link";
}
return true;
}).ToList();
if (filteredMedia.Any())
{
senders.TryGetValue(message.SenderId, out var sender);
result.Add(new SharedMediaDto(
message.Id,
message.Content,
message.CreatedAt,
null,
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : null,
message.ReplyToId,
message.Quote,
message.StoryId,
message.StoryMediaUrl,
message.StoryMediaType,
message.IsEdited,
message.Type,
filteredMedia.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList()
));
}
}
return Result.Success(result.OrderByDescending(x => x.CreatedAt).ToList());
}
}

View File

@@ -0,0 +1,86 @@
using MediatR;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Infrastructure.SignalR;
using Knot.Shared.Kernel;
namespace Knot.Modules.Chats.Application.Messages.React;
public sealed record AddReactionCommand(
Guid MessageId,
Guid UserId,
string Emoji,
Guid ChatId) : ICommand;
public sealed class AddReactionCommandHandler : ICommandHandler<AddReactionCommand>
{
private readonly IMessageRepository _messageRepository;
private readonly IChatsUnitOfWork _unitOfWork;
private readonly IHubContext<ChatHub> _hubContext;
private readonly IUserDisplayNameProvider _displayNameProvider;
private readonly ILogger<AddReactionCommandHandler> _logger;
public AddReactionCommandHandler(
IMessageRepository messageRepository,
IChatsUnitOfWork unitOfWork,
IHubContext<ChatHub> hubContext,
IUserDisplayNameProvider displayNameProvider,
ILogger<AddReactionCommandHandler> logger)
{
_messageRepository = messageRepository;
_unitOfWork = unitOfWork;
_hubContext = hubContext;
_displayNameProvider = displayNameProvider;
_logger = logger;
}
public async Task<Result> Handle(AddReactionCommand request, CancellationToken cancellationToken)
{
_logger.LogInformation("AddReaction: MessageId={MessageId}, UserId={UserId}, Emoji={Emoji}, ChatId={ChatId}",
request.MessageId, request.UserId, request.Emoji, request.ChatId);
var success = await _messageRepository.AddReactionAsync(
request.MessageId,
request.UserId,
request.Emoji,
cancellationToken);
if (!success)
{
_logger.LogWarning("AddReaction: Message not found {MessageId}", request.MessageId);
return Result.Failure(ChatErrors.MessagesNotFound);
}
await _unitOfWork.SaveChangesAsync(cancellationToken);
_logger.LogInformation("AddReaction: Reaction saved to database");
var username = await _displayNameProvider.GetDisplayNameAsync(request.UserId, cancellationToken);
_logger.LogInformation("AddReaction: Sending reaction_added to group {ChatId}", request.ChatId);
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("reaction_added", new
{
messageId = request.MessageId,
chatId = request.ChatId,
userId = request.UserId,
username = username,
emoji = request.Emoji
});
_logger.LogInformation("AddReaction: reaction_added sent successfully");
return Result.Success();
}
}

View File

@@ -0,0 +1,70 @@
using MediatR;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Infrastructure.SignalR;
using Knot.Shared.Kernel;
namespace Knot.Modules.Chats.Application.Messages.React;
public sealed record RemoveReactionCommand(
Guid MessageId,
Guid UserId,
string Emoji,
Guid ChatId) : ICommand;
public sealed class RemoveReactionCommandHandler : ICommandHandler<RemoveReactionCommand>
{
private readonly IMessageRepository _messageRepository;
private readonly IChatsUnitOfWork _unitOfWork;
private readonly IHubContext<ChatHub> _hubContext;
private readonly ILogger<RemoveReactionCommandHandler> _logger;
public RemoveReactionCommandHandler(
IMessageRepository messageRepository,
IChatsUnitOfWork unitOfWork,
IHubContext<ChatHub> hubContext,
ILogger<RemoveReactionCommandHandler> logger)
{
_messageRepository = messageRepository;
_unitOfWork = unitOfWork;
_hubContext = hubContext;
_logger = logger;
}
public async Task<Result> Handle(RemoveReactionCommand request, CancellationToken cancellationToken)
{
_logger.LogInformation("RemoveReaction: MessageId={MessageId}, UserId={UserId}, Emoji={Emoji}, ChatId={ChatId}",
request.MessageId, request.UserId, request.Emoji, request.ChatId);
var success = await _messageRepository.RemoveReactionAsync(
request.MessageId,
request.UserId,
request.Emoji,
cancellationToken);
if (!success)
{
_logger.LogWarning("RemoveReaction: Reaction not found");
// Не возвращаем ошибку - реакция уже удалена или не существовала
}
await _unitOfWork.SaveChangesAsync(cancellationToken);
_logger.LogInformation("RemoveReaction: Reaction removed from database");
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("reaction_removed", new
{
messageId = request.MessageId,
chatId = request.ChatId,
userId = request.UserId,
emoji = request.Emoji
});
_logger.LogInformation("RemoveReaction: reaction_removed sent successfully");
return Result.Success();
}
}

View File

@@ -0,0 +1,34 @@
using MediatR;
using Knot.Modules.Chats.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.Abstractions;
namespace Knot.Modules.Chats.Application.Messages.Read;
public sealed record ReadMessagesCommand(Guid ChatId, Guid UserId, List<Guid> MessageIds) : ICommand;
public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCommand>
{
private readonly IMessageRepository _messageRepository;
private readonly IChatsUnitOfWork _unitOfWork;
public ReadMessagesCommandHandler(IMessageRepository messageRepository, IChatsUnitOfWork unitOfWork)
{
_messageRepository = messageRepository;
_unitOfWork = unitOfWork;
}
public async Task<Result> Handle(ReadMessagesCommand request, CancellationToken cancellationToken)
{
if (request.MessageIds == null || !request.MessageIds.Any())
{
return Result.Success();
}
await _messageRepository.AddReadReceiptsAsync(request.UserId, request.MessageIds, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success();
}
}

View File

@@ -0,0 +1,61 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.DTOs;
using Knot.Modules.Chats.Domain;
namespace Knot.Modules.Chats.Application.Messages.SearchMessages;
public record SearchMessagesQuery(Guid UserId, string Query, Guid? ChatId) : IQuery<List<SearchMessageDto>>;
internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQuery, List<SearchMessageDto>>
{
private readonly IMessageRepository _messageRepository;
private readonly IUserDisplayNameProvider _userProvider;
public SearchMessagesQueryHandler(IMessageRepository messageRepository, IUserDisplayNameProvider userProvider)
{
_messageRepository = messageRepository;
_userProvider = userProvider;
}
public async Task<Result<List<SearchMessageDto>>> Handle(SearchMessagesQuery request, CancellationToken cancellationToken)
{
var messages = await _messageRepository.SearchMessagesAsync(request.Query, request.ChatId, request.UserId, cancellationToken);
messages = messages.Where(message => !message.IsDeletedForUser(request.UserId)).ToList();
var userIds = messages.Select(message => message.SenderId).ToList();
userIds.AddRange(messages.Where(message => message.ForwardedFromId.HasValue).Select(message => message.ForwardedFromId!.Value));
var senders = await _userProvider.GetUsersInfoAsync(userIds.Distinct(), cancellationToken);
var result = messages.Select(message => new SearchMessageDto(
message.Id,
message.ChatId,
message.SenderId,
message.Content,
message.Type,
message.ReplyToId,
message.Quote,
message.IsEdited,
message.IsDeleted,
message.CreatedAt,
message.ForwardedFromId,
null,
message.StoryId,
message.StoryMediaUrl,
message.StoryMediaType,
message.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
message.Reactions.Select(reaction => new SimpleReactionDto(reaction.UserId, reaction.Emoji)).ToList(),
message.ReadBy.Select(readReceipt => new ReadByDto(readReceipt.UserId)).ToList()
)).ToList();
return Result.Success(result);
}
}

View File

@@ -0,0 +1,127 @@
using Knot.Modules.Chats.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.Abstractions;
namespace Knot.Modules.Chats.Application.Messages.Send;
/// <summary>
/// Команда для отправки сообщения в чат.
/// </summary>
public record AttachmentRequest(string Type, string Url, string? FileName, long? FileSize);
public sealed record SendMessageCommand(
Guid ChatId,
Guid SenderId,
string? Content,
string Type,
List<AttachmentRequest>? Attachments = null,
Guid? ReplyToId = null,
string? Quote = null,
Guid? ForwardedFromId = null,
Guid? StoryId = null,
string? StoryMediaUrl = null,
string? StoryMediaType = null) : ICommand<Guid>;
public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageCommand, Guid>
{
private readonly IChatRepository _chatRepository;
private readonly IMessageRepository _messageRepository;
private readonly IChatsUnitOfWork _unitOfWork;
private readonly MediatR.IMediator _mediator;
public SendMessageCommandHandler(
IChatRepository chatRepository,
IMessageRepository messageRepository,
IChatsUnitOfWork unitOfWork,
MediatR.IMediator mediator)
{
_chatRepository = chatRepository;
_messageRepository = messageRepository;
_unitOfWork = unitOfWork;
_mediator = mediator;
}
public async Task<Result<Guid>> Handle(SendMessageCommand request, CancellationToken cancellationToken)
{
// 1. Проверяем существование чата
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat is null)
{
return Result.Failure<Guid>(ChatErrors.ChatsNotFound);
}
// 2. Проверяем, является ли отправитель участником
if (!chat.Members.Any(m => m.UserId == request.SenderId))
{
return Result.Failure<Guid>(ChatErrors.ChatsForbidden);
}
// 3. Создаем сообщение
Message message;
if (request.Type == "story_reply" || request.Type == "story_reaction")
{
var parsedStoryMediaType = Enum.TryParse<MediaType>(request.StoryMediaType, true, out var sTypeEnum) ? sTypeEnum : MediaType.Image;
message = new StoryMessage(
Guid.NewGuid(),
request.ChatId,
request.SenderId,
request.StoryId ?? Guid.Empty,
request.StoryMediaUrl ?? string.Empty,
parsedStoryMediaType,
request.Content,
request.ReplyToId,
request.ForwardedFromId,
DateTime.UtcNow,
false);
}
else if (request.Attachments != null && request.Attachments.Any())
{
var firstAtt = request.Attachments.First();
var parsedType = Enum.TryParse<MediaType>(firstAtt.Type, true, out var mTypeEnum) ? mTypeEnum : MediaType.File;
message = new MediaMessage(
Guid.NewGuid(),
request.ChatId,
request.SenderId,
parsedType,
request.Content,
request.ReplyToId,
request.ForwardedFromId,
DateTime.UtcNow,
false);
foreach (var att in request.Attachments)
{
var pType = Enum.TryParse<MediaType>(att.Type, true, out var tEnum) ? tEnum : MediaType.File;
((MediaMessage)message).AddMedia(pType, att.Url, att.FileName, att.FileSize);
}
}
else
{
message = new TextMessage(
Guid.NewGuid(),
request.ChatId,
request.SenderId,
request.Content ?? string.Empty,
request.ReplyToId,
request.Quote,
request.ForwardedFromId,
DateTime.UtcNow,
false);
}
// 4. Сохраняем
_messageRepository.Add(message);
await _unitOfWork.SaveChangesAsync(cancellationToken);
await _mediator.Publish(new MessageSentDomainEvent(
message.Id,
message.ChatId,
message.SenderId,
message.Content),
cancellationToken);
return Result.Success(message.Id);
}
}

View File

@@ -0,0 +1,44 @@
using Knot.Modules.Chats.Domain;
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using Knot.Shared.Kernel.Configuration;
using Knot.Modules.Chats.Application.DTOs;
namespace Knot.Modules.Chats.Application.Messages.UploadFile;
public record UploadFileCommand(string FileName, string ContentType, long Length, Stream FileStream) : ICommand<UploadFileResponseDto>;
internal sealed class UploadFileCommandHandler : ICommandHandler<UploadFileCommand, UploadFileResponseDto>
{
private readonly IFileStorageService _fileStorage;
private readonly ISettingsService _settingsService;
public UploadFileCommandHandler(IFileStorageService fileStorage, ISettingsService settingsService)
{
_fileStorage = fileStorage;
_settingsService = settingsService;
}
public async Task<Result<UploadFileResponseDto>> Handle(UploadFileCommand request, CancellationToken cancellationToken)
{
if (request.Length == 0)
{
return Result.Failure<UploadFileResponseDto>(ChatErrors.FileEmpty);
}
var maxMb = _settingsService.Current.MaxFileSizeMb;
if (request.Length > maxMb * 1024 * 1024)
{
return Result.Failure<UploadFileResponseDto>(ChatErrors.FileTooLarge(maxMb));
}
var fileId = await _fileStorage.UploadFileAsync(request.FileStream, request.FileName, request.ContentType);
return Result.Success(new UploadFileResponseDto("/api/files/" + fileId, request.FileName, request.Length));
}
}

View File

@@ -0,0 +1,93 @@
using Knot.Modules.Chats.Domain;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AngleSharp.Html.Parser;
using AngleSharp.Dom;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Chats.Application.TelegramImport;
public static class TelegramImportState
{
public static readonly ConcurrentDictionary<Guid, string> TempZips = new();
}
public record AnalyzeImportResponseDto(Guid Token, List<string> Names);
public record AnalyzeImportCommand(Stream FileStream, string FileName) : ICommand<AnalyzeImportResponseDto>;
internal sealed class AnalyzeImportCommandHandler : ICommandHandler<AnalyzeImportCommand, AnalyzeImportResponseDto>
{
public async Task<Result<AnalyzeImportResponseDto>> Handle(AnalyzeImportCommand request, CancellationToken cancellationToken)
{
if (request.FileStream == null || request.FileStream.Length == 0)
{
return Result.Failure<AnalyzeImportResponseDto>(ChatErrors.FileEmpty);
}
if (!request.FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
return Result.Failure<AnalyzeImportResponseDto>(ChatErrors.FileInvalidExtension);
}
var token = Guid.NewGuid();
var tempPath = Path.Combine(Path.GetTempPath(), $"{token}.zip");
await using (var fs = new FileStream(tempPath, FileMode.Create))
{
await request.FileStream.CopyToAsync(fs, cancellationToken);
}
var names = new HashSet<string>();
using (var archive = ZipFile.OpenRead(tempPath))
{
var htmlEntries = archive.Entries
.Where(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase) && e.Name.StartsWith("messages", StringComparison.OrdinalIgnoreCase));
foreach (var entry in htmlEntries)
{
using var stream = entry.Open();
var parser = new HtmlParser();
var doc = parser.ParseDocument(stream);
var messageNodes = doc.QuerySelectorAll(".message");
if (messageNodes == null)
{
continue;
}
foreach (var node in messageNodes)
{
var fromNameNode = node.QuerySelector(".from_name");
if (fromNameNode != null)
{
var nameNodeText = (IElement)fromNameNode.Clone();
var innerSpans = nameNodeText.QuerySelectorAll("span");
foreach (var span in innerSpans)
{
span.Remove();
}
var name = nameNodeText.TextContent.Trim();
if (!string.IsNullOrWhiteSpace(name))
{
names.Add(name);
}
}
}
}
}
TelegramImportState.TempZips[token] = tempPath;
return Result.Success(new AnalyzeImportResponseDto(token, names.ToList()));
}
}

View File

@@ -0,0 +1,455 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AngleSharp.Dom;
using AngleSharp.Html.Parser;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Chats.Application.Chats.Create;
using Knot.Modules.Chats.Infrastructure.SignalR;
using Microsoft.AspNetCore.SignalR;
namespace Knot.Modules.Chats.Application.TelegramImport;
public record ExecuteImportResponseDto(bool Success, int MessagesImported, Guid ChatId);
public record ExecuteImportCommand(
Guid CurrentUserId,
Guid Token,
Dictionary<string, Guid> Mapping,
string? GroupName
) : ICommand<ExecuteImportResponseDto>;
internal sealed class ExecuteImportCommandHandler : ICommandHandler<ExecuteImportCommand, ExecuteImportResponseDto>
{
private readonly ISender _sender;
private readonly IChatsUnitOfWork _uow;
private readonly IChatRepository _chatRepository;
private readonly IMessageRepository _messageRepository;
private readonly IFileStorageService _fileStorage;
private readonly IHubContext<ChatHub> _hubContext;
public ExecuteImportCommandHandler(
ISender sender,
IChatsUnitOfWork uow,
IChatRepository chatRepository,
IMessageRepository messageRepository,
IFileStorageService fileStorage,
IHubContext<ChatHub> hubContext)
{
_sender = sender;
_uow = uow;
_chatRepository = chatRepository;
_messageRepository = messageRepository;
_fileStorage = fileStorage;
_hubContext = hubContext;
}
public async Task<Result<ExecuteImportResponseDto>> Handle(ExecuteImportCommand request, CancellationToken cancellationToken)
{
if (!TelegramImportState.TempZips.TryGetValue(request.Token, out var tempPath))
{
return Result.Failure<ExecuteImportResponseDto>(ChatErrors.ImportExpired);
}
if (!System.IO.File.Exists(tempPath))
{
return Result.Failure<ExecuteImportResponseDto>(ChatErrors.ImportMissing);
}
var myId = request.CurrentUserId;
var targetUserIds = request.Mapping.Values.Distinct().Where(id => id != Guid.Empty).ToList();
if (!targetUserIds.Contains(myId))
{
targetUserIds.Add(myId);
}
Guid chatId = Guid.Empty;
var chatMembers = targetUserIds;
if (chatMembers.Count <= 2)
{
var existingChats = await _chatRepository.GetUserChatsAsync(myId, cancellationToken);
var personalChat = existingChats.FirstOrDefault(c => c.Type == ChatType.Personal && c.Members.All(m => chatMembers.Contains(m.UserId)) && c.Members.Count == chatMembers.Count);
if (personalChat != null)
{
chatId = personalChat.Id;
}
else
{
var friendId = chatMembers.FirstOrDefault(id => id != myId);
if (friendId == Guid.Empty)
{
friendId = myId;
}
var command = new CreateChatCommand(string.Empty, ChatType.Personal, new List<Guid> { myId, friendId });
var res = await _sender.Send(command, cancellationToken);
if (res.IsFailure)
{
return Result.Failure<ExecuteImportResponseDto>(ChatErrors.ImportCreateChatFailed(res.Error.Description ?? res.Error.Code));
}
chatId = res.Value;
}
}
else
{
var command = new CreateChatCommand(request.GroupName ?? "Импортированный чат", ChatType.Group, chatMembers);
var res = await _sender.Send(command, cancellationToken);
if (res.IsFailure)
{
return Result.Failure<ExecuteImportResponseDto>(ChatErrors.ImportCreateChatFailed(res.Error.Description ?? res.Error.Code));
}
chatId = res.Value;
}
int importedCount = 0;
using (var archive = ZipFile.OpenRead(tempPath))
{
var htmlEntries = archive.Entries
.Where(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase) && e.Name.StartsWith("messages", StringComparison.OrdinalIgnoreCase))
.OrderBy(e =>
{
var name = e.Name.ToLower().Replace("messages", "").Replace(".html", "");
return string.IsNullOrEmpty(name) ? 0 : int.TryParse(name, out var num) ? num : 999999;
})
.ToList();
Guid lastSenderGuid = myId;
DateTime lastCreatedAt = DateTime.UtcNow;
Dictionary<string, Guid> messageIdMap = new();
Message? lastSavedMessage = null;
foreach (var entry in htmlEntries)
{
using var stream = entry.Open();
var parser = new HtmlParser();
var doc = parser.ParseDocument(stream);
var messageNodes = doc.QuerySelectorAll(".message");
if (messageNodes == null)
{
continue;
}
var baseDir = Path.GetDirectoryName(entry.FullName)?.Replace("\\", "/") ?? "";
if (!string.IsNullOrEmpty(baseDir) && !baseDir.EndsWith("/"))
{
baseDir += "/";
}
foreach (var node in messageNodes)
{
try
{
var fromNameNode = node.QuerySelector(".from_name");
var textNode = node.QuerySelector(".text");
var dateNode = node.QuerySelector(".date[title]")
?? node.QuerySelector(".pull_right[title]")
?? node.QuerySelector("[title]");
if (fromNameNode != null)
{
var nameNodeText = (AngleSharp.Dom.IElement)fromNameNode.Clone();
var innerSpans = nameNodeText.QuerySelectorAll("span");
foreach (var span in innerSpans)
{
span.Remove();
}
var name = nameNodeText.TextContent.Trim();
if (request.Mapping.TryGetValue(name, out var mappedId) && mappedId != Guid.Empty)
{
lastSenderGuid = mappedId;
}
else
{
lastSenderGuid = myId;
}
}
Guid senderGuid = lastSenderGuid;
string content = "";
var mainBodyNode = node.QuerySelector(".body");
var isForwarded = node.QuerySelector(".forwarded") != null;
var contentTextNode = isForwarded
? (node.QuerySelector(".body > .text") ?? node.QuerySelector(".text:not(.forwarded .text)"))
: node.QuerySelector(".text");
if (contentTextNode != null)
{
var html = contentTextNode.InnerHtml
.Replace("<br>", "\n", StringComparison.OrdinalIgnoreCase)
.Replace("<br/>", "\n", StringComparison.OrdinalIgnoreCase)
.Replace("<br />", "\n", StringComparison.OrdinalIgnoreCase);
var tempParser = new HtmlParser();
var tempDoc = tempParser.ParseDocument("<div>" + html + "</div>");
content = tempDoc.Body?.TextContent.Trim() ?? "";
}
DateTime createdAt = lastCreatedAt;
var titleNodes = node.QuerySelectorAll("[title]");
bool parsed = false;
if (titleNodes != null)
{
foreach (var tnode in titleNodes)
{
var dateStr = tnode.GetAttribute("title")?.Trim() ?? "";
if (dateStr.Length >= 10 && char.IsDigit(dateStr[0]) && char.IsDigit(dateStr[1]))
{
var cleanStr = dateStr.Replace("UTC", "", StringComparison.OrdinalIgnoreCase).Trim();
if (DateTimeOffset.TryParseExact(cleanStr, "dd.MM.yyyy HH:mm:ss zzz", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dto))
{
createdAt = dto.UtcDateTime;
parsed = true;
break;
}
else if (DateTime.TryParseExact(cleanStr, "dd.MM.yyyy HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out var dt))
{
createdAt = dt;
parsed = true;
break;
}
else if (DateTime.TryParse(cleanStr, out var dFallback))
{
createdAt = dFallback.ToUniversalTime();
parsed = true;
break;
}
}
}
}
if (!parsed)
{
Console.WriteLine("Warning: Could not parse date in imported message! Using lastCreatedAt.");
}
else
{
lastCreatedAt = createdAt;
}
Guid? forwardedFromId = null;
var forwardedNode = node.QuerySelector(".forwarded.body");
if (forwardedNode != null)
{
var fwdNameNode = forwardedNode.QuerySelector(".from_name");
var fwdNameText = fwdNameNode != null ? (AngleSharp.Dom.IElement)fwdNameNode.Clone() : null;
if (fwdNameText != null)
{
var innerSpans = fwdNameText.QuerySelectorAll("span");
foreach (var s in innerSpans)
{
s.Remove();
}
}
var fwdName = fwdNameText != null ? fwdNameText.TextContent.Trim() : "Неизвестного";
if (request.Mapping.TryGetValue(fwdName, out var mappedFwdId) && mappedFwdId != Guid.Empty)
{
forwardedFromId = mappedFwdId;
}
else if (fwdName == "Это я" || fwdName == request.Mapping.FirstOrDefault(x => x.Value == myId).Key)
{
forwardedFromId = myId;
}
var fwdTextNode = forwardedNode.QuerySelector(".text");
string fwdContent = "";
if (fwdTextNode != null)
{
var fHtml = fwdTextNode.InnerHtml
.Replace("<br>", "\n", StringComparison.OrdinalIgnoreCase)
.Replace("<br/>", "\n", StringComparison.OrdinalIgnoreCase)
.Replace("<br />", "\n", StringComparison.OrdinalIgnoreCase);
var tempParser = new HtmlParser();
var tempDoc = tempParser.ParseDocument("<div>" + fHtml + "</div>");
fwdContent = tempDoc.Body?.TextContent.Trim() ?? "";
}
if (forwardedFromId == null)
{
content = string.IsNullOrEmpty(content)
? $"[Переслано от {fwdName}]:\n{fwdContent}"
: $"{content}\n\n[Переслано от {fwdName}]:\n{fwdContent}";
}
else if (string.IsNullOrEmpty(content))
{
content = fwdContent;
}
}
Guid? replyToId = null;
var replyNode = node.QuerySelector(".reply_to a");
if (replyNode != null)
{
var href = replyNode.GetAttribute("href");
if (href != null && href.StartsWith("#go_to_"))
{
var tgId = href.Substring("#go_to_".Length);
if (messageIdMap.TryGetValue(tgId, out var mappedMsgId))
{
replyToId = mappedMsgId;
}
}
}
var mediaNodes = node.QuerySelectorAll("a.photo_wrap, a.animated_wrap, video, audio, a.document, a.media_voice_message, a.media_video, img.sticker").ToList();
if (mediaNodes.Count == 0)
{
var fallback = node.QuerySelectorAll(".media_wrap a[href]");
mediaNodes.AddRange(fallback);
}
var messageType = "text";
(string mType, string cType) GetMediaTypes(string fileUrl)
{
var ext = Path.GetExtension(fileUrl)?.ToLower();
return ext switch
{
".jpg" or ".jpeg" or ".png" or ".webp" => ("image", "image/jpeg"),
".mp4" or ".mov" or ".avi" => ("video", "video/mp4"),
".ogg" or ".mp3" => ("voice", "audio/ogg"),
_ => ("file", "application/octet-stream")
};
}
if (mediaNodes != null && mediaNodes.Count > 0)
{
var firstHref = mediaNodes[0].GetAttribute("href") ?? mediaNodes[0].GetAttribute("src");
if (firstHref != null)
{
messageType = GetMediaTypes(firstHref).mType;
if (mediaNodes[0].ClassName?.Contains("animated") == true || firstHref.EndsWith(".mp4"))
{
if (mediaNodes[0].ClassName?.Contains("animated") == true)
{
messageType = "image";
}
}
}
}
bool isJoined = fromNameNode == null;
bool isMediaOnly = string.IsNullOrEmpty(content) && forwardedNode == null && replyToId == null;
bool hasMedia = mediaNodes != null && mediaNodes.Count > 0;
Message? targetMessage = null;
if (isJoined && isMediaOnly && lastSavedMessage is MediaMessage && Math.Abs((createdAt - lastSavedMessage.CreatedAt).TotalSeconds) <= 60 && lastSavedMessage.SenderId == senderGuid)
{
targetMessage = lastSavedMessage;
}
else
{
string finalContent = content;
if (hasMedia)
{
targetMessage = new MediaMessage(Guid.NewGuid(), chatId, senderGuid, MediaType.File, finalContent, replyToId, forwardedFromId, createdAt, true);
}
else
{
targetMessage = new TextMessage(Guid.NewGuid(), chatId, senderGuid, finalContent, replyToId, null, forwardedFromId, createdAt, true);
}
var idAttr = node.GetAttribute("id");
if (!string.IsNullOrEmpty(idAttr))
{
messageIdMap[idAttr] = targetMessage.Id;
}
}
if (hasMedia)
{
foreach (var mediaNode in mediaNodes!)
{
string? href = mediaNode.GetAttribute("href") ?? mediaNode.GetAttribute("src");
if (!string.IsNullOrEmpty(href) && !href.StartsWith("http"))
{
var zipPath = baseDir + href.Replace("\\", "/");
var zipEntry = archive.GetEntry(zipPath);
if (zipEntry != null)
{
using var ms = new MemoryStream();
using var zipfs = zipEntry.Open();
await zipfs.CopyToAsync(ms, cancellationToken);
ms.Position = 0;
var types = GetMediaTypes(href);
var finalMType = types.mType;
if (mediaNode.ClassName?.Contains("animated") == true)
{
finalMType = "image";
}
var parsedType = Enum.TryParse<MediaType>(finalMType, true, out var mTypeEnum) ? mTypeEnum : MediaType.File;
if (targetMessage is MediaMessage mm)
{
var fileId = await _fileStorage.UploadFileAsync(ms, Path.GetFileName(href), types.cType);
mm.AddMedia(parsedType, $"/api/files/{fileId}", Path.GetFileName(href), zipEntry.Length);
}
}
}
}
}
var reactionNodes = node.QuerySelectorAll(".reactions .reaction");
foreach (var reactionNode in reactionNodes)
{
var emojiNode = reactionNode.QuerySelector(".emoji");
if (emojiNode == null)
{
continue;
}
var emoji = emojiNode.TextContent.Trim();
var userpicNodes = reactionNode.QuerySelectorAll(".userpics .userpic .initials[title]");
foreach (var userpicNode in userpicNodes)
{
var title = userpicNode.GetAttribute("title")?.Trim();
if (!string.IsNullOrEmpty(title) && request.Mapping.TryGetValue(title, out var rUserId) && rUserId != Guid.Empty)
{
targetMessage.AddReaction(rUserId, emoji);
}
}
}
if (targetMessage != lastSavedMessage && (!string.IsNullOrEmpty(content) || targetMessage.Media.Any()))
{
_messageRepository.Add(targetMessage);
lastSavedMessage = targetMessage;
importedCount++;
}
}
catch { /* ignore single message parse error */ }
}
}
}
await _uow.SaveChangesAsync(cancellationToken);
try { System.IO.File.Delete(tempPath); TelegramImportState.TempZips.TryRemove(request.Token, out _); } catch { }
await _hubContext.Clients.Users(chatMembers.Select(x => x.ToString())).SendAsync("history_updated", new { chatId });
return Result.Success(new ExecuteImportResponseDto(true, importedCount, chatId));
}
}

View File

@@ -0,0 +1,48 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Chats.Domain;
using Knot.Shared.Kernel;
using MongoDB.Driver;
using Knot.Modules.Chats.Infrastructure.Persistence;
using Knot.Modules.Chats.Infrastructure.Persistence.Mongo;
using Knot.Modules.Chats.Application.Abstractions;
namespace Knot.Modules.Chats;
public static class DependencyInjection
{
/// <summary>
/// Регистрация сервисов модуля Chats.
/// </summary>
public static IServiceCollection AddChatsModule(
this IServiceCollection services,
IConfiguration configuration)
{
// Настройка базы данных
string connectionString = configuration.GetConnectionString("DefaultConnection")!;
services.AddDbContext<ChatsDbContext>(options =>
options.UseNpgsql(connectionString));
// MongoDB Setup for Messages
MongoDbMapConfigurator.Configure();
var mongoConnectionString = configuration.GetConnectionString("MongoConnection") ?? "mongodb://localhost:27017";
services.AddSingleton<IMongoClient>(new MongoClient(mongoConnectionString));
services.AddScoped<IMongoDatabase>(sp =>
sp.GetRequiredService<IMongoClient>().GetDatabase("forkmessager_chats"));
// Registration
services.AddScoped<IChatsUnitOfWork>(sp => sp.GetRequiredService<ChatsDbContext>());
services.AddScoped<IChatRepository, ChatRepository>();
services.AddScoped<IMessageRepository, MessageRepository>();
// MediatR
services.AddMediatR(config =>
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
return services;
}
}

View File

@@ -0,0 +1,132 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Chats.Domain;
public sealed record ChatCreatedDomainEvent(Chat Chat) : IDomainEvent;
public sealed record ChatMemberAddedDomainEvent(Guid ChatId, Guid UserId) : IDomainEvent;
/// <summary>
/// Тип чата: личный или групповой.
/// </summary>
public enum ChatType
{
Personal,
Group,
Favorites
}
/// <summary>
/// Роль участника в чате.
/// </summary>
public static class ChatRole
{
public const string Owner = "owner";
public const string Admin = "admin";
public const string Member = "member";
}
/// <summary>
/// Сущность чата (Агрегат).
/// </summary>
public sealed class Chat : AggregateRoot<Guid>
{
public ChatType Type { get; private set; }
public string? Name { get; private set; }
public string? Description { get; private set; }
public string? Avatar { get; private set; }
public DateTime CreatedAt { get; private set; }
private readonly List<ChatMember> _members = new();
public IReadOnlyCollection<ChatMember> Members => _members.AsReadOnly();
private Chat(Guid id, ChatType type, string? name, string? avatar, string? description = null) : base(id)
{
Type = type;
Name = name;
Avatar = avatar;
Description = description;
CreatedAt = DateTime.UtcNow;
}
/// <summary>
/// Создает личный чат между двумя пользователями.
/// </summary>
public static Chat CreatePersonal()
{
var chat = new Chat(Guid.NewGuid(), ChatType.Personal, null, null);
chat.RaiseDomainEvent(new ChatCreatedDomainEvent(chat));
return chat;
}
/// <summary>
/// Создает групповой чат.
/// </summary>
public static Chat CreateGroup(string name, string? avatar = null)
{
var chat = new Chat(Guid.NewGuid(), ChatType.Group, name, avatar);
chat.RaiseDomainEvent(new ChatCreatedDomainEvent(chat));
return chat;
}
/// <summary>
/// Фабричный метод для создания чата.
/// </summary>
public static Chat Create(string? name, ChatType type, string? avatar = null, string? description = null)
{
var chat = new Chat(Guid.NewGuid(), type, name, avatar, description);
chat.RaiseDomainEvent(new ChatCreatedDomainEvent(chat));
return chat;
}
public void AddMember(Guid userId, string role = "member")
{
if (_members.Any(m => m.UserId == userId))
{
return;
}
_members.Add(new ChatMember(Id, userId, role));
RaiseDomainEvent(new ChatMemberAddedDomainEvent(Id, userId));
}
public void RemoveMember(Guid userId)
{
var member = _members.FirstOrDefault(m => m.UserId == userId);
if (member != null)
{
_members.Remove(member);
}
}
public void UpdateName(string name) => Name = name;
public void UpdateDescription(string? description) => Description = description;
public void UpdateAvatar(string? avatarUrl) => Avatar = avatarUrl;
}
/// <summary>
/// Участник чата.
/// </summary>
public sealed class ChatMember : Entity<Guid>
{
public Guid ChatId { get; private set; }
public Guid UserId { get; private set; }
public string Role { get; private set; }
public DateTime JoinedAt { get; private set; }
public bool IsPinned { get; private set; }
public bool IsMuted { get; private set; }
// For EF Core
private ChatMember() : base(Guid.Empty) { Role = "member"; }
internal ChatMember(Guid chatId, Guid userId, string role) : base(Guid.NewGuid())
{
ChatId = chatId;
UserId = userId;
Role = role;
JoinedAt = DateTime.UtcNow;
}
public void TogglePin() => IsPinned = !IsPinned;
}

View File

@@ -0,0 +1,9 @@
namespace Knot.Modules.Chats.Domain;
public static class ChatConstants
{
public const int DefaultMessageQueryLimit = 100;
public const int MaxSharedMediaQueryLimit = 300;
public const int SearchMessagesLimit = 50;
public const int MaxFileUploadSizeMb = 50;
}

View File

@@ -0,0 +1,19 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Chats.Domain;
public static class ChatErrors
{
public static readonly Error FileEmpty = new Error("File.Empty", "No file uploaded");
public static readonly Error FileInvalidExtension = new Error("File.InvalidExtension", "Must be a ZIP archive");
public static readonly Error ImportExpired = new Error("Import.Expired", "Session not found or expired");
public static readonly Error ImportMissing = new Error("Import.Missing", "ZIP file lost");
public static readonly Error ChatNotFound = new Error("Chat.NotFound", "Chat not found or access denied");
public static readonly Error ChatsForbidden = new Error("Chats.Forbidden", "Вы не являетесь участником этого чата.");
public static readonly Error MessagesNotFound = new Error("Messages.NotFound", "Message not found.");
public static readonly Error ChatsNotFound = new Error("Chats.NotFound", "Чат не найден.");
public static readonly Error Unauthorized = new Error("Chats.Unauthorized", "Access denied");
public static Error ImportCreateChatFailed(string msg) => new Error("Import.CreateChatFailed", msg);
public static Error FileTooLarge(int maxMb) => new Error("File.TooLarge", $"File exceeds the maximum allowed size of {maxMb}MB.");
}

View File

@@ -0,0 +1,21 @@
using System;
using Knot.Shared.Kernel;
namespace Knot.Modules.Chats.Domain;
/// <summary>
/// Запись о том, что конкретный пользователь удалил у себя сообщение.
/// </summary>
public sealed class DeletedMessage : Entity<Guid>
{
public Guid MessageId { get; private set; }
public Guid UserId { get; private set; }
internal DeletedMessage(Guid messageId, Guid userId) : base(Guid.NewGuid())
{
MessageId = messageId;
UserId = userId;
}
private DeletedMessage() : base(Guid.Empty) { }
}

View File

@@ -0,0 +1,13 @@
using Knot.Modules.Chats.Domain;
namespace Knot.Modules.Chats.Domain;
public interface IChatRepository
{
void Add(Chat chat);
void Update(Chat chat);
void Remove(Chat chat);
Task<Chat?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
Task<Chat?> GetFavoritesAsync(Guid userId, CancellationToken cancellationToken);
Task<List<Chat>> GetUserChatsAsync(Guid userId, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,19 @@
using Knot.Modules.Chats.Domain;
namespace Knot.Modules.Chats.Domain;
public interface IMessageRepository
{
void Add(Message message);
Task<Message?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
Task<List<Message>> GetChatMessagesAsync(Guid chatId, int limit, int offset, CancellationToken cancellationToken);
Task<Message?> GetLatestChatMessageAsync(Guid chatId, CancellationToken cancellationToken);
Task<List<Message>> SearchMessagesAsync(string query, Guid? chatId, Guid requestingUserId, CancellationToken cancellationToken);
Task AddReadReceiptsAsync(Guid userId, List<Guid> messageIds, CancellationToken cancellationToken);
Task<bool> AddReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken);
Task<bool> RemoveReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken);
Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, int limit, CancellationToken cancellationToken);
Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
Task<int> GetUnreadCountAsync(Guid chatId, Guid userId, CancellationToken cancellationToken);
Task UpdateAsync(Message message, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,31 @@
using System;
using Knot.Shared.Kernel;
namespace Knot.Modules.Chats.Domain;
/// <summary>
/// Медиа-файл, прикрепленный к сообщению.
/// </summary>
public sealed class Media : Entity<Guid>
{
public Guid MessageId { get; private set; }
public string Type { get; private set; }
public string Url { get; private set; }
public string? Filename { get; private set; }
public long? Size { get; private set; }
internal Media(Guid messageId, string type, string url, string? filename, long? size) : base(Guid.NewGuid())
{
MessageId = messageId;
Type = type;
Url = url;
Filename = filename;
Size = size;
}
private Media() : base(Guid.Empty)
{
Type = string.Empty;
Url = string.Empty;
}
}

View File

@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Chats.Domain;
public class MediaMessage : Message
{
public override string Type => MediaType.ToString().ToLower();
public override string? Content { get; protected set; } // Map to Caption
public string? Caption { get => Content; private set => Content = value; }
public MediaType MediaType { get; private set; } // image, video, file, voice
private readonly List<Media> _media = new();
public override IReadOnlyCollection<Media> Media => _media.AsReadOnly();
private MediaMessage() : base()
{
MediaType = MediaType.File;
}
public MediaMessage(
Guid id,
Guid chatId,
Guid senderId,
MediaType mediaType,
string? caption,
Guid? replyToId,
Guid? forwardedFromId,
DateTime createdAt,
bool isImported)
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported)
{
MediaType = mediaType;
Caption = caption;
if (!isImported)
{
RaiseDomainEvent(new MessageSentDomainEvent(Id, ChatId, SenderId, Caption));
}
}
public void AddMedia(MediaType type, string url, string? filename, long? size)
{
_media.Add(new Domain.Media(Id, type.ToString().ToLower(), url, filename, size));
}
public void Edit(string newCaption)
{
Caption = newCaption;
AddState(MessageState.IsEdited);
}
public override void Delete()
{
Caption = null;
base.Delete();
}
}

View File

@@ -0,0 +1,12 @@
namespace Knot.Modules.Chats.Domain;
/// <summary>
/// Тип медиа-контента.
/// </summary>
public enum MediaType
{
Image,
Video,
Voice,
File
}

View File

@@ -0,0 +1,106 @@
using System;
using System.Collections.Generic;
using Knot.Shared.Kernel;
namespace Knot.Modules.Chats.Domain;
/// <summary>
/// Абстрактная база агрегата Сообщение.
/// </summary>
public abstract class Message : AggregateRoot<Guid>
{
// ================== Базовые поля ==================
public Guid ChatId { get; protected set; }
public Guid SenderId { get; protected set; }
public DateTime CreatedAt { get; protected set; }
// ================== Опциональные метаданные (общего назначения) ==================
public Guid? ReplyToId { get; protected set; }
public Guid? ForwardedFromId { get; protected set; }
// ================== Флаги ==================
public MessageState State { get; protected set; }
// ================== Абстрактные / Виртуальные свойства ==================
public abstract string Type { get; }
public abstract string? Content { get; protected set; }
public virtual string? Quote { get; protected set; } = null;
public virtual Guid? StoryId => null;
public virtual string? StoryMediaUrl => null;
public virtual string? StoryMediaType => null;
public virtual IReadOnlyCollection<Media> Media => Array.Empty<Media>();
public bool IsEdited => HasState(MessageState.IsEdited);
public bool IsDeleted => HasState(MessageState.IsDeleted);
public bool IsDeletedForUser(Guid userId) => _deletedFor.Exists(d => d.UserId == userId);
// ================== Связанные коллекции (общего назначения) ==================
protected readonly List<ReadReceipt> _readBy = new();
public IReadOnlyCollection<ReadReceipt> ReadBy => _readBy.AsReadOnly();
protected readonly List<DeletedMessage> _deletedFor = new();
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
protected readonly List<Reaction> _reactions = new();
public IReadOnlyCollection<Reaction> Reactions => _reactions.AsReadOnly();
// ================== Инфраструктурный конструктор EF ==================
protected Message() : base(Guid.Empty) { }
protected Message(
Guid id,
Guid chatId,
Guid senderId,
Guid? replyToId,
Guid? forwardedFromId,
DateTime createdAt,
bool isImported) : base(id)
{
ChatId = chatId;
SenderId = senderId;
ReplyToId = replyToId;
ForwardedFromId = forwardedFromId;
CreatedAt = createdAt;
if (isImported) AddState(MessageState.IsImported);
}
// ================== Управление Состоянием ==================
public void AddState(MessageState state) => State |= state;
public void RemoveState(MessageState state) => State &= ~state;
public bool HasState(MessageState state) => (State & state) == state;
// ================== Общие операции ==================
public void AddReaction(Guid userId, string emoji)
{
var existing = _reactions.Find(r => r.UserId == userId && r.Emoji == emoji);
if (existing == null)
{
_reactions.Add(new Reaction(Id, userId, emoji));
}
}
public void RemoveReaction(Guid userId, string emoji)
{
var existing = _reactions.Find(r => r.UserId == userId && r.Emoji == emoji);
if (existing != null)
{
_reactions.Remove(existing);
}
}
public virtual void Delete()
{
AddState(MessageState.IsDeleted);
_reactions.Clear();
}
public void DeleteForUser(Guid userId)
{
if (!_deletedFor.Exists(x => x.UserId == userId))
{
_deletedFor.Add(new DeletedMessage(Id, userId));
}
}
}

View File

@@ -0,0 +1,16 @@
using System;
namespace Knot.Modules.Chats.Domain;
/// <summary>
/// Флаги состояния сообщения
/// </summary>
[Flags]
public enum MessageFlags
{
None = 0,
IsEdited = 1,
IsDeleted = 2,
IsImported = 4,
IsPinned = 8
}

View File

@@ -0,0 +1,9 @@
using System;
using Knot.Shared.Kernel;
namespace Knot.Modules.Chats.Domain;
/// <summary>
/// Доменное событие: сообщение отправлено.
/// </summary>
public sealed record MessageSentDomainEvent(Guid MessageId, Guid ChatId, Guid SenderId, string? Content) : IDomainEvent;

View File

@@ -0,0 +1,16 @@
using System;
namespace Knot.Modules.Chats.Domain;
/// <summary>
/// Состояние сообщения
/// </summary>
[Flags]
public enum MessageState
{
None = 0,
IsEdited = 1,
IsDeleted = 2,
IsImported = 4,
IsPinned = 8
}

View File

@@ -0,0 +1,26 @@
using System;
using Knot.Shared.Kernel;
namespace Knot.Modules.Chats.Domain;
/// <summary>
/// Реакция на сообщение.
/// </summary>
public sealed class Reaction : Entity<Guid>
{
public Guid MessageId { get; private set; }
public Guid UserId { get; private set; }
public string Emoji { get; private set; }
internal Reaction(Guid messageId, Guid userId, string emoji) : base(Guid.NewGuid())
{
MessageId = messageId;
UserId = userId;
Emoji = emoji;
}
private Reaction() : base(Guid.Empty)
{
Emoji = string.Empty;
}
}

View File

@@ -0,0 +1,20 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Chats.Domain;
public sealed class ReadReceipt : Entity<Guid>
{
public Guid MessageId { get; private set; }
public Guid UserId { get; private set; }
public DateTime ReadAt { get; private set; }
// Для EF Core
private ReadReceipt() : base(Guid.Empty) { }
public ReadReceipt(Guid messageId, Guid userId) : base(Guid.NewGuid())
{
MessageId = messageId;
UserId = userId;
ReadAt = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,55 @@
using System;
namespace Knot.Modules.Chats.Domain;
public class StoryMessage : Message
{
public override string Type => "story";
public override string? Content { get; protected set; }
public Guid InternalStoryId { get; private set; }
public override Guid? StoryId => InternalStoryId;
public string InternalStoryMediaUrl { get; private set; }
public override string? StoryMediaUrl => InternalStoryMediaUrl;
public MediaType InternalStoryMediaType { get; private set; }
public override string? StoryMediaType => InternalStoryMediaType.ToString().ToLower();
private StoryMessage() : base()
{
InternalStoryMediaUrl = string.Empty;
InternalStoryMediaType = MediaType.Image;
}
public StoryMessage(
Guid id,
Guid chatId,
Guid senderId,
Guid storyId,
string storyMediaUrl,
MediaType storyMediaType,
string? content,
Guid? replyToId,
Guid? forwardedFromId,
DateTime createdAt,
bool isImported)
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported)
{
InternalStoryId = storyId;
InternalStoryMediaUrl = storyMediaUrl;
InternalStoryMediaType = storyMediaType;
Content = content;
if (!isImported)
{
RaiseDomainEvent(new MessageSentDomainEvent(Id, ChatId, SenderId, Content));
}
}
public override void Delete()
{
Content = null;
base.Delete();
}
}

View File

@@ -0,0 +1,48 @@
using System;
namespace Knot.Modules.Chats.Domain;
public class TextMessage : Message
{
public override string Type => "text";
public override string? Content { get; protected set; }
public override string? Quote { get; protected set; }
private TextMessage() : base()
{
Content = string.Empty;
}
public TextMessage(
Guid id,
Guid chatId,
Guid senderId,
string content,
Guid? replyToId,
string? quote,
Guid? forwardedFromId,
DateTime createdAt,
bool isImported)
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported)
{
Content = content;
Quote = quote;
if (!isImported)
{
RaiseDomainEvent(new MessageSentDomainEvent(Id, ChatId, SenderId, Content));
}
}
public void Edit(string newContent)
{
Content = newContent;
AddState(MessageState.IsEdited);
}
public override void Delete()
{
Content = string.Empty;
base.Delete();
}
}

View File

@@ -0,0 +1,91 @@
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Infrastructure.SignalR;
using MediatR;
using Microsoft.AspNetCore.SignalR;
namespace Knot.Modules.Chats.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);
}
}
}
}

View File

@@ -0,0 +1,104 @@
using MediatR;
using Microsoft.AspNetCore.SignalR;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Infrastructure.SignalR;
using Knot.Shared.Kernel;
namespace Knot.Modules.Chats.Infrastructure.Handlers;
/// <summary>
/// Обработчик доменного события отправки сообщения.
/// Отправляет уведомление через SignalR всем участникам чата.
/// </summary>
public sealed class MessageSentDomainEventHandler : INotificationHandler<MessageSentDomainEvent>
{
private readonly IHubContext<ChatHub> _hubContext;
private readonly IMessageRepository _messageRepository;
private readonly IUserDisplayNameProvider _displayNameProvider;
public MessageSentDomainEventHandler(
IHubContext<ChatHub> hubContext,
IMessageRepository messageRepository,
IUserDisplayNameProvider displayNameProvider)
{
_hubContext = hubContext;
_messageRepository = messageRepository;
_displayNameProvider = displayNameProvider;
}
public async Task Handle(MessageSentDomainEvent notification, CancellationToken cancellationToken)
{
var message = await _messageRepository.GetByIdAsync(notification.MessageId, cancellationToken);
if (message is null)
{
return;
}
var userInfo = await _displayNameProvider.GetUserInfoAsync(message.SenderId, cancellationToken);
var senderObj = userInfo != null
? new { Id = userInfo.Id, Username = userInfo.Username, DisplayName = userInfo.DisplayName, Avatar = userInfo.Avatar }
: new { Id = message.SenderId, Username = "unknown", DisplayName = "Unknown", Avatar = (string?)null };
// Fetch forwarded from info if exists
object? forwardedFromObj = null;
if (message.ForwardedFromId.HasValue)
{
var fwdUserInfo = await _displayNameProvider.GetUserInfoAsync(message.ForwardedFromId.Value, cancellationToken);
forwardedFromObj = fwdUserInfo != null
? new { Id = fwdUserInfo.Id, Username = fwdUserInfo.Username, DisplayName = fwdUserInfo.DisplayName, Avatar = fwdUserInfo.Avatar }
: new { Id = message.ForwardedFromId.Value, Username = "unknown", DisplayName = "Unknown", Avatar = (string?)null };
}
// Fetch reply info if exists
object? replyToObj = null;
if (message.ReplyToId.HasValue)
{
var replyMsg = await _messageRepository.GetByIdAsync(message.ReplyToId.Value, cancellationToken);
if (replyMsg != null)
{
var replySenderInfo = await _displayNameProvider.GetUserInfoAsync(replyMsg.SenderId, cancellationToken);
replyToObj = new
{
Id = replyMsg.Id,
Content = replyMsg.Content,
Quote = message.Quote,
media = replyMsg.Media.Select(rm => new { rm.Id, rm.Type, rm.Url }).ToList(),
Sender = replySenderInfo != null
? new { Id = replySenderInfo.Id, Username = replySenderInfo.Username, DisplayName = replySenderInfo.DisplayName }
: new { Id = replyMsg.SenderId, Username = "unknown", DisplayName = "Unknown" }
};
}
}
// Отправляем сообщение в "комнату" чата.
await _hubContext.Clients.Group(notification.ChatId.ToString())
.SendAsync("new_message", new
{
message.Id,
message.ChatId,
message.SenderId,
Content = message.Content,
Type = message.Type,
message.CreatedAt,
message.ForwardedFromId,
ForwardedFrom = forwardedFromObj,
message.ReplyToId,
ReplyTo = replyToObj,
Quote = message.Quote,
Media = message.Media.Select(m => new
{
m.Id,
m.Type,
m.Url,
Filename = m.Filename,
Size = m.Size
}).ToList(),
Sender = senderObj,
ReadBy = new List<object>(),
StoryId = message.StoryId,
StoryMediaUrl = message.StoryMediaUrl,
StoryMediaType = message.StoryMediaType
}, cancellationToken);
}
}

View File

@@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Chats.Domain;
namespace Knot.Modules.Chats.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);
}
}

View File

@@ -0,0 +1,87 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Chats.Domain;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Security;
namespace Knot.Modules.Chats.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;
}
}

View File

@@ -0,0 +1,207 @@
using Microsoft.Extensions.Configuration;
using MongoDB.Driver;
using MongoDB.Driver.Linq;
using Knot.Modules.Chats.Domain;
using Knot.Shared.Kernel;
using System.Text.RegularExpressions;
using MongoDB.Bson;
namespace Knot.Modules.Chats.Infrastructure.Persistence;
public sealed class MessageRepository : IMessageRepository
{
private readonly IMongoCollection<Message> _messages;
private readonly ChatsDbContext _dbContext;
private readonly MediatR.IMediator _mediator;
public MessageRepository(IMongoDatabase mongoDatabase, ChatsDbContext dbContext, MediatR.IMediator mediator)
{
_messages = mongoDatabase.GetCollection<Message>("messages");
_dbContext = dbContext;
_mediator = mediator;
}
public void Add(Message message)
{
_messages.InsertOne(message);
// Publish domain events manualy for mongo entities
var events = message.GetDomainEvents().ToList();
message.ClearDomainEvents();
// This runs synchronously or without waiting, better to run async but Add is void
// In this implementation setting, fire and forget or wrap sync
foreach (var domainEvent in events)
{
_mediator.Publish(domainEvent).GetAwaiter().GetResult();
}
}
public async Task<Message?> GetByIdAsync(Guid id, CancellationToken cancellationToken)
{
var filter = Builders<Message>.Filter.Eq(m => m.Id, id);
return await _messages.Find(filter).FirstOrDefaultAsync(cancellationToken);
}
public async Task<List<Message>> GetChatMessagesAsync(Guid chatId, int limit, int offset, CancellationToken cancellationToken)
{
var filter = Builders<Message>.Filter.Eq(m => m.ChatId, chatId);
return await _messages.Find(filter)
.SortByDescending(m => m.CreatedAt)
.Skip(offset)
.Limit(limit)
.ToListAsync(cancellationToken);
}
public async Task<Message?> GetLatestChatMessageAsync(Guid chatId, CancellationToken cancellationToken)
{
var filter = Builders<Message>.Filter.Eq(m => m.ChatId, chatId);
return await _messages.Find(filter)
.SortByDescending(m => m.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, int limit, CancellationToken cancellationToken)
{
var builder = Builders<Message>.Filter;
var filter = builder.Eq(m => m.ChatId, chatId);
if (cursor.HasValue)
{
filter &= builder.Lt(m => m.CreatedAt, cursor.Value);
}
return await _messages.Find(filter)
.SortByDescending(m => m.CreatedAt)
.Limit(limit)
.ToListAsync(cancellationToken);
}
public async Task<List<Message>> SearchMessagesAsync(string query, Guid? chatId, Guid requestingUserId, CancellationToken cancellationToken)
{
// Not ideal for SQL/Mongo combination but keeping the signature
var validChatIdsQuery = _dbContext.Chats
.Where(c => c.Members.Any(m => m.UserId == requestingUserId))
.Select(c => c.Id)
.ToList();
var builder = Builders<Message>.Filter;
var filter = builder.In(m => m.ChatId, validChatIdsQuery);
if (chatId.HasValue)
{
filter &= builder.Eq(m => m.ChatId, chatId.Value);
}
var textFilter = Builders<Message>.Filter.Regex("Content", new BsonRegularExpression(Regex.Escape(query), "i"));
filter &= textFilter;
return await _messages.Find(filter)
.SortByDescending(m => m.CreatedAt)
.Limit(ChatConstants.SearchMessagesLimit)
.ToListAsync(cancellationToken);
}
public async Task AddReadReceiptsAsync(Guid userId, List<Guid> messageIds, CancellationToken cancellationToken)
{
var filter = Builders<Message>.Filter.In(m => m.Id, messageIds);
var messages = await _messages.Find(filter).ToListAsync(cancellationToken);
var writes = new List<WriteModel<Message>>();
foreach (var msg in messages)
{
if (!msg.ReadBy.Any(r => r.UserId == userId))
{
var receipt = new ReadReceipt(msg.Id, userId);
var pushUpdate = Builders<Message>.Update.Push("ReadBy", receipt);
var updateModel = new UpdateOneModel<Message>(Builders<Message>.Filter.Eq(m => m.Id, msg.Id), pushUpdate);
writes.Add(updateModel);
}
}
if (writes.Any())
{
await _messages.BulkWriteAsync(writes, cancellationToken: cancellationToken);
}
}
public async Task<bool> AddReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken)
{
var filter = Builders<Message>.Filter.Eq(m => m.Id, messageId);
var msg = await _messages.Find(filter).FirstOrDefaultAsync(cancellationToken);
if (msg == null) return false;
if (msg.Reactions.Any(r => r.UserId == userId && r.Emoji == emoji))
{
return true;
}
var reaction = new Reaction(messageId, userId, emoji);
var update = Builders<Message>.Update.Push("Reactions", reaction);
await _messages.UpdateOneAsync(filter, update, cancellationToken: cancellationToken);
return true;
}
public async Task<bool> RemoveReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken)
{
var filter = Builders<Message>.Filter.Eq(m => m.Id, messageId);
var msg = await _messages.Find(filter).FirstOrDefaultAsync(cancellationToken);
if (msg == null) return false;
var reaction = msg.Reactions.FirstOrDefault(r => r.UserId == userId && r.Emoji == emoji);
if (reaction == null) return false;
var update = Builders<Message>.Update.PullFilter("Reactions",
Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("UserId", userId),
Builders<BsonDocument>.Filter.Eq("Emoji", emoji)
));
await _messages.UpdateOneAsync(filter, update, cancellationToken: cancellationToken);
return true;
}
public async Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken)
{
var filter = Builders<Message>.Filter.And(
Builders<Message>.Filter.Eq(m => m.ChatId, chatId),
Builders<Message>.Filter.Eq("_t", "StoryMessage"),
Builders<Message>.Filter.Eq("StoryId", storyId)
);
return await _messages.Find(filter)
.SortByDescending(m => m.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<int> GetUnreadCountAsync(Guid chatId, Guid userId, CancellationToken cancellationToken)
{
var notReadFilter = Builders<Message>.Filter.Not(
Builders<Message>.Filter.ElemMatch("ReadBy",
Builders<BsonDocument>.Filter.Eq("UserId", userId))
);
var finalFilter = Builders<Message>.Filter.And(
Builders<Message>.Filter.Eq(m => m.ChatId, chatId),
Builders<Message>.Filter.Ne(m => m.SenderId, userId),
notReadFilter
);
return (int)await _messages.CountDocumentsAsync(finalFilter, cancellationToken: cancellationToken);
}
public async Task UpdateAsync(Message message, CancellationToken cancellationToken)
{
var filter = Builders<Message>.Filter.Eq(m => m.Id, message.Id);
await _messages.ReplaceOneAsync(filter, message, new ReplaceOptions { IsUpsert = true }, cancellationToken);
// Publish domain events
var events = message.GetDomainEvents().ToList();
message.ClearDomainEvents();
foreach (var domainEvent in events)
{
await _mediator.Publish(domainEvent, cancellationToken);
}
}
}

View File

@@ -0,0 +1,49 @@
using System;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers;
using Knot.Shared.Kernel.Security;
namespace Knot.Modules.Chats.Infrastructure.Persistence.Mongo;
public class EncryptedStringSerializer : SerializerBase<string>
{
public static IEncryptionService? EncryptionService { get; set; }
public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, string value)
{
if (string.IsNullOrEmpty(value) || EncryptionService == null)
{
context.Writer.WriteString(value ?? string.Empty);
return;
}
try
{
var encrypted = EncryptionService.EncryptMessage(value);
context.Writer.WriteString(encrypted);
}
catch
{
context.Writer.WriteString(value);
}
}
public override string Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args)
{
var value = context.Reader.ReadString();
if (string.IsNullOrEmpty(value) || EncryptionService == null)
{
return value;
}
try
{
return EncryptionService.DecryptMessage(value);
}
catch
{
// Fallback for already unencrypted, or failed to decrypt
return value;
}
}
}

View File

@@ -0,0 +1,74 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers;
using Knot.Modules.Chats.Domain;
using Knot.Shared.Kernel;
namespace Knot.Modules.Chats.Infrastructure.Persistence.Mongo;
public static class MongoDbMapConfigurator
{
private static bool _initialized;
public static void Configure()
{
if (_initialized) return;
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
BsonSerializer.RegisterSerializer(new EnumSerializer<MessageState>(BsonType.String));
BsonSerializer.RegisterSerializer(new EnumSerializer<MediaType>(BsonType.String));
BsonClassMap.RegisterClassMap<Entity<Guid>>(cm =>
{
cm.AutoMap();
cm.MapIdProperty(e => e.Id);
});
BsonClassMap.RegisterClassMap<Message>(cm =>
{
cm.AutoMap();
cm.MapField("_deletedFor").SetElementName("DeletedFor");
cm.MapField("_readBy").SetElementName("ReadBy");
cm.MapField("_reactions").SetElementName("Reactions");
cm.MapProperty(c => c.Quote).SetSerializer(new EncryptedStringSerializer());
cm.SetIsRootClass(true);
});
BsonClassMap.RegisterClassMap<TextMessage>(cm =>
{
cm.AutoMap();
cm.SetDiscriminator("TextMessage");
cm.MapProperty(c => c.Content).SetSerializer(new EncryptedStringSerializer());
});
BsonClassMap.RegisterClassMap<MediaMessage>(cm =>
{
cm.AutoMap();
cm.MapField("_media").SetElementName("Media");
cm.SetDiscriminator("MediaMessage");
cm.MapProperty(c => c.Caption).SetSerializer(new EncryptedStringSerializer());
});
BsonClassMap.RegisterClassMap<StoryMessage>(cm =>
{
cm.AutoMap();
cm.SetDiscriminator("StoryMessage");
cm.MapProperty(c => c.Content).SetSerializer(new EncryptedStringSerializer());
cm.MapProperty(c => c.InternalStoryMediaUrl).SetSerializer(new EncryptedStringSerializer());
});
BsonClassMap.RegisterClassMap<DeletedMessage>(cm => cm.AutoMap());
BsonClassMap.RegisterClassMap<ReadReceipt>(cm => cm.AutoMap());
BsonClassMap.RegisterClassMap<Reaction>(cm => cm.AutoMap());
BsonClassMap.RegisterClassMap<Media>(cm =>
{
cm.AutoMap();
cm.MapProperty(c => c.Url).SetSerializer(new EncryptedStringSerializer());
cm.MapProperty(c => c.Filename).SetSerializer(new EncryptedStringSerializer());
});
_initialized = true;
}
}

View File

@@ -0,0 +1,670 @@
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.Chats.Application.Messages.Send;
using Knot.Modules.Chats.Application.Messages.Read;
using Knot.Modules.Chats.Application.Messages.Delete;
using Knot.Modules.Chats.Application.Messages.React;
using Knot.Modules.Chats.Domain;
using Knot.Shared.Kernel;
using Microsoft.Extensions.Caching.Memory;
namespace Knot.Modules.Chats.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.MessageIds != null && request.MessageIds.Any())
{
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 ReadMessagesCommand(
request.ChatId, _userContext.UserId, parsedIds);
await _sender.Send(command);
}
}
await Clients.Group(request.ChatId.ToString()).SendAsync("messages_read", new
{
ChatId = request.ChatId.ToString(),
UserId = _userContext.UserId,
MessageIds = request.MessageIds ?? new List<string>()
});
}
[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, List<string>? MessageIds);
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);
}

View File

@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.4.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
<PackageReference Include="MediatR" Version="12.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.4" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="MongoDB.Driver" Version="3.7.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Knot.Modules.Chats.UnitTests" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,99 @@
// <auto-generated />
using System;
using Knot.Modules.Chats.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Knot.Modules.Chats.Migrations
{
[DbContext(typeof(ChatsDbContext))]
[Migration("20260319124845_InitialChats")]
partial class InitialChats
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("chats")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Chats", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.ChatMember", "Members", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<Guid>("ChatId")
.HasColumnType("uuid");
b1.Property<bool>("IsMuted")
.HasColumnType("boolean");
b1.Property<bool>("IsPinned")
.HasColumnType("boolean");
b1.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b1.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.HasKey("Id");
b1.HasIndex("ChatId", "UserId")
.IsUnique();
b1.ToTable("ChatMembers", "chats");
b1.WithOwner()
.HasForeignKey("ChatId");
});
b.Navigation("Members");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,79 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Chats.Migrations
{
/// <inheritdoc />
public partial class InitialChats : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "chats");
migrationBuilder.CreateTable(
name: "Chats",
schema: "chats",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: true),
Description = table.Column<string>(type: "text", nullable: true),
Avatar = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Chats", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ChatMembers",
schema: "chats",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChatId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Role = table.Column<string>(type: "text", nullable: false),
JoinedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
IsPinned = table.Column<bool>(type: "boolean", nullable: false),
IsMuted = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ChatMembers", x => x.Id);
table.ForeignKey(
name: "FK_ChatMembers_Chats_ChatId",
column: x => x.ChatId,
principalSchema: "chats",
principalTable: "Chats",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ChatMembers_ChatId_UserId",
schema: "chats",
table: "ChatMembers",
columns: new[] { "ChatId", "UserId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ChatMembers",
schema: "chats");
migrationBuilder.DropTable(
name: "Chats",
schema: "chats");
}
}
}

View File

@@ -0,0 +1,96 @@
// <auto-generated />
using System;
using Knot.Modules.Chats.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Knot.Modules.Chats.Migrations
{
[DbContext(typeof(ChatsDbContext))]
partial class ChatsDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("chats")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Chats", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.ChatMember", "Members", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<Guid>("ChatId")
.HasColumnType("uuid");
b1.Property<bool>("IsMuted")
.HasColumnType("boolean");
b1.Property<bool>("IsPinned")
.HasColumnType("boolean");
b1.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b1.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.HasKey("Id");
b1.HasIndex("ChatId", "UserId")
.IsUnique();
b1.ToTable("ChatMembers", "chats");
b1.WithOwner()
.HasForeignKey("ChatId");
});
b.Navigation("Members");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,14 @@
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Identity.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace Knot.Modules.Identity.Application.Abstractions;
public interface IIdentityDbContext
{
DbSet<User> Users { get; }
DbSet<Friendship> Friendships { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,10 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Identity.Application.Abstractions;
/// <summary>
/// Unit of Work специфичный для модуля Identity.
/// </summary>
public interface IIdentityUnitOfWork : IUnitOfWork
{
}

View File

@@ -0,0 +1,8 @@
using Knot.Modules.Identity.Domain;
namespace Knot.Modules.Identity.Application.Abstractions;
public interface IJwtTokenProvider
{
string Generate(User user);
}

View File

@@ -0,0 +1,36 @@
using Knot.Modules.Identity.Domain;
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Shared.Kernel;
namespace Knot.Modules.Identity.Application.Friends;
public record AcceptFriendRequestCommand(Guid UserId, Guid FriendshipId) : ICommand<Guid>;
internal sealed class AcceptFriendRequestCommandHandler : ICommandHandler<AcceptFriendRequestCommand, Guid>
{
private readonly IIdentityDbContext _context;
private readonly IIdentityUnitOfWork _unitOfWork;
public AcceptFriendRequestCommandHandler(IIdentityDbContext context, IIdentityUnitOfWork unitOfWork)
{
_context = context;
_unitOfWork = unitOfWork;
}
public async Task<Result<Guid>> Handle(AcceptFriendRequestCommand request, CancellationToken cancellationToken)
{
var friendship = await _context.Friendships.FindAsync(new object[] { request.FriendshipId }, cancellationToken);
if (friendship == null || friendship.FriendId != request.UserId)
{
return Result.Failure<Guid>(IdentityErrors.FriendsNotFound);
}
friendship.Accept();
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success(friendship.Id);
}
}

View File

@@ -0,0 +1,36 @@
using Knot.Modules.Identity.Domain;
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Shared.Kernel;
namespace Knot.Modules.Identity.Application.Friends;
public record DeclineFriendRequestCommand(Guid UserId, Guid FriendshipId) : ICommand;
internal sealed class DeclineFriendRequestCommandHandler : ICommandHandler<DeclineFriendRequestCommand>
{
private readonly IIdentityDbContext _context;
private readonly IIdentityUnitOfWork _unitOfWork;
public DeclineFriendRequestCommandHandler(IIdentityDbContext context, IIdentityUnitOfWork unitOfWork)
{
_context = context;
_unitOfWork = unitOfWork;
}
public async Task<Result> Handle(DeclineFriendRequestCommand request, CancellationToken cancellationToken)
{
var friendship = await _context.Friendships.FindAsync(new object[] { request.FriendshipId }, cancellationToken);
if (friendship == null || friendship.FriendId != request.UserId)
{
return Result.Failure(IdentityErrors.FriendsNotFound);
}
friendship.Decline();
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success();
}
}

View File

@@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Identity.Application.Friends;
public record FriendDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar,
bool IsOnline,
DateTime LastSeenAt,
Guid FriendshipId
);

View File

@@ -0,0 +1,11 @@
using System;
namespace Knot.Modules.Identity.Application.Friends;
public record FriendUserDto(Guid Id, string Username, string DisplayName, string? Avatar);
public record FriendRequestDto(
Guid Id,
FriendUserDto User,
DateTime CreatedAt
);

View File

@@ -0,0 +1,9 @@
using System;
namespace Knot.Modules.Identity.Application.Friends;
public record FriendshipStatusResponse(
string Status,
Guid? FriendshipId = null,
string? Direction = null
);

View File

@@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Identity.Domain;
using Knot.Shared.Kernel;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace Knot.Modules.Identity.Application.Friends;
public record GetFriendsQuery(Guid UserId) : IQuery<List<FriendDto>>;
internal sealed class GetFriendsQueryHandler : IQueryHandler<GetFriendsQuery, List<FriendDto>>
{
private readonly IIdentityDbContext _context;
private readonly IUserRepository _userRepository;
public GetFriendsQueryHandler(IIdentityDbContext context, IUserRepository userRepository)
{
_context = context;
_userRepository = userRepository;
}
public async Task<Result<List<FriendDto>>> Handle(GetFriendsQuery request, CancellationToken cancellationToken)
{
var friendships = await _context.Friendships
.Where(f => (f.UserId == request.UserId || f.FriendId == request.UserId) && f.Status == FriendshipStatus.Accepted)
.ToListAsync(cancellationToken);
var friendIds = friendships.Select(f => f.UserId == request.UserId ? f.FriendId : f.UserId).ToList();
var friends = new List<FriendDto>();
foreach (var id in friendIds)
{
var user = await _userRepository.GetByIdAsync(id, cancellationToken);
if (user == null)
{
continue;
}
var fs = friendships.First(f => f.UserId == id || f.FriendId == id);
friends.Add(new FriendDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
false, // IsOnline logic shouldn't be here, but we'll leave default for now
DateTime.UtcNow,
fs.Id
));
}
return Result.Success(friends);
}
}

View File

@@ -0,0 +1,43 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
namespace Knot.Modules.Identity.Application.Friends;
public record GetFriendshipStatusQuery(Guid CurrentUserId, Guid TargetUserId) : IQuery<FriendshipStatusResponse>;
internal sealed class GetFriendshipStatusQueryHandler : IQueryHandler<GetFriendshipStatusQuery, FriendshipStatusResponse>
{
private readonly IIdentityDbContext _context;
public GetFriendshipStatusQueryHandler(IIdentityDbContext context)
{
_context = context;
}
public async Task<Result<FriendshipStatusResponse>> Handle(GetFriendshipStatusQuery request, CancellationToken cancellationToken)
{
if (request.CurrentUserId == request.TargetUserId)
{
return Result.Success(new FriendshipStatusResponse("self"));
}
var fs = await _context.Friendships
.FirstOrDefaultAsync(f => (f.UserId == request.CurrentUserId && f.FriendId == request.TargetUserId) ||
(f.UserId == request.TargetUserId && f.FriendId == request.CurrentUserId), cancellationToken);
if (fs == null)
{
return Result.Success(new FriendshipStatusResponse("none"));
}
return Result.Success(new FriendshipStatusResponse(
fs.Status.ToString().ToLowerInvariant(),
fs.Id,
fs.UserId == request.CurrentUserId ? "outgoing" : "incoming"
));
}
}

View File

@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Identity.Domain;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
namespace Knot.Modules.Identity.Application.Friends;
public record GetIncomingRequestsQuery(Guid UserId) : IQuery<List<FriendRequestDto>>;
internal sealed class GetIncomingRequestsQueryHandler : IQueryHandler<GetIncomingRequestsQuery, List<FriendRequestDto>>
{
private readonly IIdentityDbContext _context;
private readonly IUserRepository _userRepository;
public GetIncomingRequestsQueryHandler(IIdentityDbContext context, IUserRepository userRepository)
{
_context = context;
_userRepository = userRepository;
}
public async Task<Result<List<FriendRequestDto>>> Handle(GetIncomingRequestsQuery request, CancellationToken cancellationToken)
{
var friendships = await _context.Friendships
.Where(f => f.FriendId == request.UserId && f.Status == FriendshipStatus.Pending)
.ToListAsync(cancellationToken);
var requestsList = new List<FriendRequestDto>();
foreach (var fs in friendships)
{
var user = await _userRepository.GetByIdAsync(fs.UserId, cancellationToken);
if (user == null)
{
continue;
}
requestsList.Add(new FriendRequestDto(
fs.Id,
new FriendUserDto(user.Id, user.Username, user.DisplayName, user.Avatar),
fs.CreatedAt
));
}
return Result.Success(requestsList);
}
}

View File

@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Identity.Domain;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
namespace Knot.Modules.Identity.Application.Friends;
public record GetOutgoingRequestsQuery(Guid UserId) : IQuery<List<FriendRequestDto>>;
internal sealed class GetOutgoingRequestsQueryHandler : IQueryHandler<GetOutgoingRequestsQuery, List<FriendRequestDto>>
{
private readonly IIdentityDbContext _context;
private readonly IUserRepository _userRepository;
public GetOutgoingRequestsQueryHandler(IIdentityDbContext context, IUserRepository userRepository)
{
_context = context;
_userRepository = userRepository;
}
public async Task<Result<List<FriendRequestDto>>> Handle(GetOutgoingRequestsQuery request, CancellationToken cancellationToken)
{
var friendships = await _context.Friendships
.Where(f => f.UserId == request.UserId && f.Status == FriendshipStatus.Pending)
.ToListAsync(cancellationToken);
var requestsList = new List<FriendRequestDto>();
foreach (var fs in friendships)
{
var user = await _userRepository.GetByIdAsync(fs.FriendId, cancellationToken);
if (user == null)
{
continue;
}
requestsList.Add(new FriendRequestDto(
fs.Id,
new FriendUserDto(user.Id, user.Username, user.DisplayName, user.Avatar),
fs.CreatedAt
));
}
return Result.Success(requestsList);
}
}

View File

@@ -0,0 +1,36 @@
using Knot.Modules.Identity.Domain;
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Shared.Kernel;
namespace Knot.Modules.Identity.Application.Friends;
public record RemoveFriendCommand(Guid UserId, Guid FriendshipId) : ICommand;
internal sealed class RemoveFriendCommandHandler : ICommandHandler<RemoveFriendCommand>
{
private readonly IIdentityDbContext _context;
private readonly IIdentityUnitOfWork _unitOfWork;
public RemoveFriendCommandHandler(IIdentityDbContext context, IIdentityUnitOfWork unitOfWork)
{
_context = context;
_unitOfWork = unitOfWork;
}
public async Task<Result> Handle(RemoveFriendCommand request, CancellationToken cancellationToken)
{
var friendship = await _context.Friendships.FindAsync(new object[] { request.FriendshipId }, cancellationToken);
if (friendship == null || (friendship.UserId != request.UserId && friendship.FriendId != request.UserId))
{
return Result.Failure(IdentityErrors.FriendsNotFound);
}
_context.Friendships.Remove(friendship);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success();
}
}

View File

@@ -0,0 +1,46 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Identity.Domain;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
namespace Knot.Modules.Identity.Application.Friends;
public record SendFriendRequestCommand(Guid UserId, Guid FriendId) : ICommand;
internal sealed class SendFriendRequestCommandHandler : ICommandHandler<SendFriendRequestCommand>
{
private readonly IIdentityDbContext _context;
private readonly IIdentityUnitOfWork _unitOfWork;
public SendFriendRequestCommandHandler(IIdentityDbContext context, IIdentityUnitOfWork unitOfWork)
{
_context = context;
_unitOfWork = unitOfWork;
}
public async Task<Result> Handle(SendFriendRequestCommand request, CancellationToken cancellationToken)
{
if (request.UserId == request.FriendId)
{
return Result.Failure(IdentityErrors.FriendsSelf);
}
var existing = await _context.Friendships
.FirstOrDefaultAsync(f => (f.UserId == request.UserId && f.FriendId == request.FriendId) ||
(f.UserId == request.FriendId && f.FriendId == request.UserId), cancellationToken);
if (existing != null)
{
return Result.Failure(IdentityErrors.FriendsExists);
}
var friendship = Friendship.Create(request.UserId, request.FriendId);
_context.Friendships.Add(friendship);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success();
}
}

View File

@@ -0,0 +1,20 @@
using System;
namespace Knot.Modules.Identity.Application.Users.Auth;
public record AuthResponseDto(
string Token,
AuthUserDto User
);
public record AuthUserDto(
Guid Id,
string Username,
string DisplayName,
string? Email,
string? Bio,
string? Avatar,
DateTime? Birthday,
bool IsOnline,
DateTime CreatedAt
);

View File

@@ -0,0 +1,68 @@
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Shared.Kernel.Storage;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
namespace Knot.Modules.Identity.Application.Users.Avatar;
public sealed record CropAvatarCommand(Guid UserId, Stream FileStream, string FileName, string ContentType, int X, int Y, int Width, int Height) : ICommand<UserProfileDto>;
internal sealed class CropAvatarCommandHandler : ICommandHandler<CropAvatarCommand, UserProfileDto>
{
private readonly IUserRepository _userRepository;
private readonly IIdentityUnitOfWork _unitOfWork;
private readonly IFileStorageService _fileStorage;
public CropAvatarCommandHandler(IUserRepository userRepository, IIdentityUnitOfWork unitOfWork, IFileStorageService fileStorage)
{
_userRepository = userRepository;
_unitOfWork = unitOfWork;
_fileStorage = fileStorage;
}
public async Task<Result<UserProfileDto>> Handle(CropAvatarCommand request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null)
{
return Result.Failure<UserProfileDto>(IdentityErrors.UserNotFound);
}
string avatarUrl;
using (var image = await SixLabors.ImageSharp.Image.LoadAsync(request.FileStream, cancellationToken))
{
int startX = Math.Max(0, Math.Min(request.X, image.Width - 1));
int startY = Math.Max(0, Math.Min(request.Y, image.Height - 1));
int rectWidth = Math.Max(1, Math.Min(request.Width, image.Width - startX));
int rectHeight = Math.Max(1, Math.Min(request.Height, image.Height - startY));
image.Mutate(ctx => ctx.Crop(new SixLabors.ImageSharp.Rectangle(startX, startY, rectWidth, rectHeight)));
image.Mutate(ctx => ctx.Resize(400, 400));
using var outStream = new MemoryStream();
await image.SaveAsJpegAsync(outStream, cancellationToken);
outStream.Position = 0;
var id = await _fileStorage.UploadFileAsync(outStream, request.FileName, "image/jpeg");
avatarUrl = $"/api/files/{id}";
}
user.UpdateAvatar(avatarUrl);
await _unitOfWork.SaveChangesAsync(cancellationToken);
var dto = new UserProfileDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
user.Bio,
user.Birthday,
user.CreatedAt
);
return Result.Success(dto);
}
}

View File

@@ -0,0 +1,43 @@
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Application.Abstractions;
namespace Knot.Modules.Identity.Application.Users.Avatar;
public sealed record DeleteAvatarCommand(Guid UserId) : ICommand<UserProfileDto>;
internal sealed class DeleteAvatarCommandHandler : ICommandHandler<DeleteAvatarCommand, UserProfileDto>
{
private readonly IUserRepository _userRepository;
private readonly IIdentityUnitOfWork _unitOfWork;
public DeleteAvatarCommandHandler(IUserRepository userRepository, IIdentityUnitOfWork unitOfWork)
{
_userRepository = userRepository;
_unitOfWork = unitOfWork;
}
public async Task<Result<UserProfileDto>> Handle(DeleteAvatarCommand request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null)
{
return Result.Failure<UserProfileDto>(IdentityErrors.UserNotFound);
}
user.UpdateAvatar(null);
await _unitOfWork.SaveChangesAsync(cancellationToken);
var dto = new UserProfileDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
user.Bio,
user.Birthday,
user.CreatedAt
);
return Result.Success(dto);
}
}

View File

@@ -0,0 +1,49 @@
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Shared.Kernel.Storage;
namespace Knot.Modules.Identity.Application.Users.Avatar;
public sealed record UploadAvatarCommand(Guid UserId, Stream FileStream, string FileName, string ContentType) : ICommand<UserProfileDto>;
internal sealed class UploadAvatarCommandHandler : ICommandHandler<UploadAvatarCommand, UserProfileDto>
{
private readonly IUserRepository _userRepository;
private readonly IIdentityUnitOfWork _unitOfWork;
private readonly IFileStorageService _fileStorage;
public UploadAvatarCommandHandler(IUserRepository userRepository, IIdentityUnitOfWork unitOfWork, IFileStorageService fileStorage)
{
_userRepository = userRepository;
_unitOfWork = unitOfWork;
_fileStorage = fileStorage;
}
public async Task<Result<UserProfileDto>> Handle(UploadAvatarCommand request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null)
{
return Result.Failure<UserProfileDto>(IdentityErrors.UserNotFound);
}
var fileId = await _fileStorage.UploadFileAsync(request.FileStream, request.FileName, request.ContentType);
var avatarUrl = $"/api/files/{fileId}";
user.UpdateAvatar(avatarUrl);
await _unitOfWork.SaveChangesAsync(cancellationToken);
var dto = new UserProfileDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
user.Bio,
user.Birthday,
user.CreatedAt
);
return Result.Success(dto);
}
}

View File

@@ -0,0 +1,44 @@
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Identity.Application.Users.Auth;
namespace Knot.Modules.Identity.Application.Users.GetMe;
public sealed record GetMeQuery(Guid UserId) : IQuery<AuthResponseDto>;
internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponseDto>
{
private readonly IUserRepository _userRepository;
public GetMeQueryHandler(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public async Task<Result<AuthResponseDto>> Handle(GetMeQuery request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null)
{
return Result.Failure<AuthResponseDto>(IdentityErrors.UserNotFound);
}
var response = new AuthResponseDto(
string.Empty,
new AuthUserDto(
user.Id,
user.Username,
user.DisplayName,
user.Email,
user.Bio,
user.Avatar,
user.Birthday,
true,
user.CreatedAt
)
);
return Result.Success(response);
}
}

View File

@@ -0,0 +1,41 @@
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Application.Abstractions;
namespace Knot.Modules.Identity.Application.Users.GetUser;
public sealed record GetUserQuery(Guid Id) : IQuery<UserProfileDto>;
internal sealed class GetUserQueryHandler : IQueryHandler<GetUserQuery, UserProfileDto>
{
private readonly IUserRepository _userRepository;
public GetUserQueryHandler(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public async Task<Result<UserProfileDto>> Handle(GetUserQuery request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.Id, cancellationToken);
if (user == null)
{
return Result.Failure<UserProfileDto>(IdentityErrors.UserNotFound);
}
var dto = new UserProfileDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
user.Bio,
user.Birthday,
user.CreatedAt,
null,
false,
DateTime.UtcNow
);
return Result.Success(dto);
}
}

View File

@@ -0,0 +1,51 @@
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Identity.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Application.Users.Auth;
using BCrypt.Net;
namespace Knot.Modules.Identity.Application.Users.Login;
/// <summary>
/// Команда для входа пользователя. Возвращает AuthResponseDto.
/// </summary>
public sealed record LoginUserCommand(string Username, string Password) : ICommand<AuthResponseDto>;
public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand, AuthResponseDto>
{
private readonly IUserRepository _userRepository;
private readonly IJwtTokenProvider _tokenProvider;
public LoginUserCommandHandler(IUserRepository userRepository, IJwtTokenProvider tokenProvider)
{
_userRepository = userRepository;
_tokenProvider = tokenProvider;
}
public async Task<Result<AuthResponseDto>> Handle(LoginUserCommand request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByUsernameAsync(request.Username, cancellationToken);
if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
{
return Result.Failure<AuthResponseDto>(IdentityErrors.IdentityInvalidCredentials);
}
string token = _tokenProvider.Generate(user);
return Result.Success(new AuthResponseDto(
token,
new AuthUserDto(
user.Id,
user.Username,
user.DisplayName,
user.Email,
user.Bio,
user.Avatar,
user.Birthday,
true, // IsOnline (placeholder)
user.CreatedAt
)
));
}
}

View File

@@ -0,0 +1,87 @@
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Identity.Domain;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
using Knot.Modules.Identity.Application.Users.Auth;
using BCrypt.Net;
namespace Knot.Modules.Identity.Application.Users.Register;
/// <summary>
/// Команда для регистрации нового пользователя.
/// </summary>
public sealed record RegisterUserCommand(
string Username,
string Password,
string DisplayName,
string? Email,
string? Bio) : ICommand<AuthResponseDto>;
/// <summary>
/// Обработчик команды регистрации.
/// </summary>
public sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCommand, AuthResponseDto>
{
private readonly IUserRepository _userRepository;
private readonly IIdentityUnitOfWork _unitOfWork;
private readonly ISettingsService _settings;
private readonly IJwtTokenProvider _tokenProvider;
public RegisterUserCommandHandler(
IUserRepository userRepository,
IIdentityUnitOfWork unitOfWork,
ISettingsService settings,
IJwtTokenProvider tokenProvider)
{
_userRepository = userRepository;
_unitOfWork = unitOfWork;
_settings = settings;
_tokenProvider = tokenProvider;
}
public async Task<Result<AuthResponseDto>> Handle(RegisterUserCommand request, CancellationToken cancellationToken)
{
if (!_settings.Current.EnableRegistration)
{
return Result.Failure<AuthResponseDto>(IdentityErrors.IdentityRegistrationDisabled);
}
// 1. Проверка уникальности username
if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken))
{
return Result.Failure<AuthResponseDto>(IdentityErrors.IdentityUsernameNotUnique);
}
// 2. Хеширование пароля (здесь будет вызов сервиса, пока заглушка)
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
// 3. Создание сущности
var user = User.Create(
request.Username,
passwordHash,
request.DisplayName,
request.Email,
request.Bio);
// 4. Сохранение
_userRepository.Add(user);
await _unitOfWork.SaveChangesAsync(cancellationToken);
string token = _tokenProvider.Generate(user);
return Result.Success(new AuthResponseDto(
token,
new AuthUserDto(
user.Id,
user.Username,
user.DisplayName,
user.Email,
user.Bio,
user.Avatar,
user.Birthday,
true, // IsOnline (placeholder)
user.CreatedAt
)
));
}
}

View File

@@ -0,0 +1,33 @@
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Application.Abstractions;
namespace Knot.Modules.Identity.Application.Users.Search;
public sealed record SearchUsersQuery(string Query) : IQuery<List<UserDto>>;
internal sealed class SearchUsersQueryHandler : IQueryHandler<SearchUsersQuery, List<UserDto>>
{
private readonly IUserRepository _userRepository;
public SearchUsersQueryHandler(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public async Task<Result<List<UserDto>>> Handle(SearchUsersQuery request, CancellationToken cancellationToken)
{
var users = await _userRepository.SearchUsersAsync(request.Query, cancellationToken);
var result = users.Select(user => new UserDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
false,
DateTime.UtcNow
)).ToList();
return Result.Success(result);
}
}

View File

@@ -0,0 +1,43 @@
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Application.Abstractions;
namespace Knot.Modules.Identity.Application.Users.UpdateProfile;
public sealed record UpdateProfileCommand(Guid UserId, string? DisplayName, string? Bio, DateTime? Birthday) : ICommand<UserProfileDto>;
internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfileCommand, UserProfileDto>
{
private readonly IUserRepository _userRepository;
private readonly IIdentityUnitOfWork _unitOfWork;
public UpdateProfileCommandHandler(IUserRepository userRepository, IIdentityUnitOfWork unitOfWork)
{
_userRepository = userRepository;
_unitOfWork = unitOfWork;
}
public async Task<Result<UserProfileDto>> Handle(UpdateProfileCommand request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null)
{
return Result.Failure<UserProfileDto>(IdentityErrors.UserNotFound);
}
user.UpdateProfile(request.DisplayName ?? user.DisplayName, request.Bio, request.Birthday);
await _unitOfWork.SaveChangesAsync(cancellationToken);
var dto = new UserProfileDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
user.Bio,
user.Birthday,
user.CreatedAt
);
return Result.Success(dto);
}
}

View File

@@ -0,0 +1,44 @@
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Application.Abstractions;
namespace Knot.Modules.Identity.Application.Users.UpdateSettings;
public sealed record UpdateSettingsCommand(Guid UserId, bool? HideStoryViews) : ICommand<UserProfileDto>;
internal sealed class UpdateSettingsCommandHandler : ICommandHandler<UpdateSettingsCommand, UserProfileDto>
{
private readonly IUserRepository _userRepository;
private readonly IIdentityUnitOfWork _unitOfWork;
public UpdateSettingsCommandHandler(IUserRepository userRepository, IIdentityUnitOfWork unitOfWork)
{
_userRepository = userRepository;
_unitOfWork = unitOfWork;
}
public async Task<Result<UserProfileDto>> Handle(UpdateSettingsCommand request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null)
{
return Result.Failure<UserProfileDto>(IdentityErrors.UserNotFound);
}
user.UpdateSettings(request.HideStoryViews ?? user.HideStoryViews);
await _unitOfWork.SaveChangesAsync(cancellationToken);
var dto = new UserProfileDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
user.Bio,
user.Birthday,
user.CreatedAt,
user.HideStoryViews
);
return Result.Success(dto);
}
}

View File

@@ -0,0 +1,12 @@
using System;
namespace Knot.Modules.Identity.Application.Users;
public record UserDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar,
bool IsOnline,
DateTime LastSeen
);

View File

@@ -0,0 +1,16 @@
using System;
namespace Knot.Modules.Identity.Application.Users;
public record UserProfileDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar,
string? Bio,
DateTime? Birthday,
DateTime CreatedAt,
bool? HideStoryViews = null,
bool IsOnline = false,
DateTime? LastSeen = null
);

View File

@@ -0,0 +1,40 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Identity.Infrastructure.Authentication;
using Knot.Shared.Kernel;
namespace Knot.Modules.Identity;
public static class DependencyInjection
{
/// <summary>
/// Регистрация сервисов модуля Identity.
/// </summary>
public static IServiceCollection AddIdentityModule(
this IServiceCollection services,
IConfiguration configuration)
{
// Настройка базы данных
string connectionString = configuration.GetConnectionString("DefaultConnection")!;
services.AddDbContext<IdentityDbContext>(options =>
options.UseNpgsql(connectionString));
// Регистрация Unit of Work и Репозиториев
services.AddScoped<IIdentityUnitOfWork>(sp => sp.GetRequiredService<IdentityDbContext>());
services.AddScoped<IIdentityDbContext>(sp => sp.GetRequiredService<IdentityDbContext>());
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IJwtTokenProvider, JwtTokenProvider>();
services.AddScoped<IUserDisplayNameProvider, Knot.Modules.Identity.Infrastructure.Services.UserDisplayNameProvider>();
// Регистрация MediatR для этого модуля
services.AddMediatR(config =>
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
return services;
}
}

View File

@@ -0,0 +1,34 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Identity.Domain;
public enum FriendshipStatus
{
Pending,
Accepted,
Declined
}
public sealed class Friendship : Entity<Guid>
{
public Guid UserId { get; private set; }
public Guid FriendId { get; private set; }
public FriendshipStatus Status { get; private set; }
public DateTime CreatedAt { get; private set; }
private Friendship(Guid id, Guid userId, Guid friendId, FriendshipStatus status) : base(id)
{
UserId = userId;
FriendId = friendId;
Status = status;
CreatedAt = DateTime.UtcNow;
}
public static Friendship Create(Guid userId, Guid friendId)
{
return new Friendship(Guid.NewGuid(), userId, friendId, FriendshipStatus.Pending);
}
public void Accept() => Status = FriendshipStatus.Accepted;
public void Decline() => Status = FriendshipStatus.Declined;
}

Some files were not shown because too many files have changed in this diff Show More