Структура

This commit is contained in:
Халимов Рустам
2026-03-30 01:45:19 +03:00
parent 89b2eeea6c
commit 9438cf1b35
55 changed files with 955 additions and 398 deletions
@@ -1,88 +0,0 @@
using FluentAssertions;
using NSubstitute;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Modules.Auth.Application.Users.Login;
using Knot.Contracts.Auth.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Auth.Application.Users.Auth;
using Xunit;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Auth.UnitTests;
public class LoginUserCommandHandlerTests
{
private readonly IUserRepository _userRepository;
private readonly IJwtTokenProvider _tokenProvider;
private readonly LoginUserCommandHandler _handler;
public LoginUserCommandHandlerTests()
{
_userRepository = Substitute.For<IUserRepository>();
_tokenProvider = Substitute.For<IJwtTokenProvider>();
_handler = new LoginUserCommandHandler(_userRepository, _tokenProvider);
}
[Fact]
public async Task Handle_ShouldReturnToken_WhenCredentialsAreValid()
{
// Arrange
var password = "password123";
var passwordHash = BCrypt.Net.BCrypt.HashPassword(password);
var user = User.Create("testuser", passwordHash, "Test User", null, null);
var command = new LoginUserCommand("testuser", password);
_userRepository.GetByUsernameAsync(command.Username, Arg.Any<CancellationToken>())
.Returns(user);
_tokenProvider.Generate(user).Returns("valid-jwt-token");
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Token.Should().Be("valid-jwt-token");
result.Value.User.Username.Should().Be("testuser");
}
[Fact]
public async Task Handle_ShouldReturnFailure_WhenUserDoesNotExist()
{
// Arrange
var command = new LoginUserCommand("nonexistent", "password123");
_userRepository.GetByUsernameAsync(command.Username, Arg.Any<CancellationToken>())
.Returns((User)null!);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Code.Should().Be(AuthErrors.IdentityInvalidCredentials.Code);
}
[Fact]
public async Task Handle_ShouldReturnFailure_WhenPasswordIsInvalid()
{
// Arrange
var correctPassword = "correctPassword";
var passwordHash = BCrypt.Net.BCrypt.HashPassword(correctPassword);
var user = User.Create("testuser", passwordHash, "Test User", null, null);
var command = new LoginUserCommand("testuser", "wrongPassword");
_userRepository.GetByUsernameAsync(command.Username, Arg.Any<CancellationToken>())
.Returns(user);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Code.Should().Be(AuthErrors.IdentityInvalidCredentials.Code);
}
}
@@ -8,7 +8,8 @@ using NSubstitute;
using Xunit; using Xunit;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Domain; using Knot.Modules.Conversations.Domain;
using Knot.Modules.Messaging.Domain; using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions; using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.Chats.GetChats; using Knot.Modules.Conversations.Application.Chats.GetChats;
using Knot.Modules.Conversations.Application.DTOs; using Knot.Modules.Conversations.Application.DTOs;
@@ -1,10 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<!-- Temporarily disabled - needs refactoring to use Contracts instead of Modules -->
<GenerateTargetFrameworkAttribute>false</GenerateTargetFrameworkAttribute>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -23,10 +24,9 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\..\src\Modules\Chats\Knot.Modules.Conversations.csproj" /> <ProjectReference Include="..\..\..\src\Contracts\Messaging\Knot.Contracts.Messaging.csproj" />
<ProjectReference Include="..\..\..\src\Modules\Messaging\Knot.Modules.Messaging.csproj" />
<ProjectReference Include="..\..\..\src\Modules\Conversations\Knot.Modules.Conversations.csproj" /> <ProjectReference Include="..\..\..\src\Modules\Conversations\Knot.Modules.Conversations.csproj" />
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" /> <ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -9,7 +9,7 @@ using NSubstitute;
using Xunit; using Xunit;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Domain; using Knot.Modules.Conversations.Domain;
using Knot.Modules.Messaging.Domain; using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Modules.Conversations.Application.Abstractions; using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.Messages.Send; using Knot.Modules.Conversations.Application.Messages.Send;
using Knot.Contracts.Settings.Application.Abstractions; using Knot.Contracts.Settings.Application.Abstractions;
@@ -0,0 +1,8 @@
using Knot.Shared.Kernel;
namespace Knot.Contracts.Auth.Domain;
/// <summary>
/// Доменное событие: изменился статус пользователя (онлайн/офлайн).
/// </summary>
public sealed record UserStatusChangedDomainEvent(Guid UserId, bool IsOnline, DateTime LastSeen) : IDomainEvent;
@@ -0,0 +1,7 @@
using Knot.Shared.Kernel;
namespace Knot.Contracts.Conversations.Application.Abstractions;
public interface IChatsUnitOfWork : IUnitOfWork
{
}
@@ -0,0 +1,8 @@
using System;
namespace Knot.Contracts.Conversations.Application.Abstractions;
public interface IUserStatusService
{
bool IsUserOnline(string userId);
}
@@ -0,0 +1,146 @@
using Knot.Shared.Kernel;
namespace Knot.Contracts.Conversations.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; }
public long LastMessageSequenceId { 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;
}
public static Chat CreatePersonal()
{
var chat = new Chat(Guid.NewGuid(), ChatType.Personal, null, null);
chat.RaiseDomainEvent(new ChatCreatedDomainEvent(chat));
return chat;
}
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;
}
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;
public long IncrementSequenceId()
{
return ++LastMessageSequenceId;
}
}
/// <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; }
public Guid? LastReadMessageId { get; private set; }
public long LastReadSequenceId { get; private set; }
public Guid? LastDeliveredMessageId { get; private set; }
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;
public void UpdateReadCursor(Guid messageId, long sequenceId)
{
if (sequenceId > LastReadSequenceId)
{
LastReadMessageId = messageId;
LastReadSequenceId = sequenceId;
}
}
public void UpdateDeliveredCursor(Guid messageId)
{
LastDeliveredMessageId = messageId;
}
}
@@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
using Knot.Shared.Kernel;
namespace Knot.Contracts.Conversations.Domain;
/// <summary>
/// Сущность папки для группировки чатов.
/// </summary>
public sealed class Folder : AggregateRoot<Guid>
{
public string Name { get; private set; }
public string? Icon { get; private set; }
public bool IsDefault { get; private set; }
public FolderType Type { get; private set; }
public Folder(Guid id, string name, string? icon = null, bool isDefault = false, FolderType type = FolderType.Custom)
: base(id)
{
Name = name;
Icon = icon;
IsDefault = isDefault;
Type = type;
}
public void Update(string name, string? icon)
{
if (IsDefault) throw new InvalidOperationException("Cannot rename default folders.");
Name = name;
Icon = icon;
}
}
public enum FolderType
{
All,
New,
Muted,
Custom
}
/// <summary>
/// Настройки конкретного чата для конкретного пользователя.
/// </summary>
public sealed class UserChatSettings : Entity<Guid>
{
public Guid UserId { get; private set; }
public Guid ChatId { get; private set; }
private readonly List<Guid> _folderIds = new();
public IReadOnlyCollection<Guid> FolderIds => _folderIds.AsReadOnly();
public bool IsMuted { get; private set; }
private UserChatSettings() : base(Guid.NewGuid()) { }
public UserChatSettings(Guid userId, Guid chatId) : base(Guid.NewGuid())
{
UserId = userId;
ChatId = chatId;
}
public static UserChatSettings Create(Guid userId, Guid chatId) => new(userId, chatId);
public void AddToFolder(Guid folderId)
{
if (!_folderIds.Contains(folderId)) _folderIds.Add(folderId);
}
public void RemoveFromFolder(Guid folderId)
{
_folderIds.Remove(folderId);
}
public void SetMute(bool isMuted) => IsMuted = isMuted;
}
/// <summary>
/// Глобальные настройки папок пользователя.
/// </summary>
public sealed class UserFolderSettings : AggregateRoot<Guid>
{
public Guid UserId { get; private set; }
public List<Guid> HiddenDefaultFolderIds { get; private set; } = new();
public List<Guid> CustomFolderIds { get; private set; } = new();
public UserFolderSettings(Guid userId) : base(Guid.NewGuid())
{
UserId = userId;
}
public void HideFolder(Guid folderId)
{
if (!HiddenDefaultFolderIds.Contains(folderId)) HiddenDefaultFolderIds.Add(folderId);
}
public void ShowFolder(Guid folderId)
{
HiddenDefaultFolderIds.Remove(folderId);
}
}
@@ -0,0 +1,39 @@
using Knot.Contracts.Conversations.Domain;
namespace Knot.Contracts.Conversations.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);
}
public interface IFolderRepository
{
void Add(Folder folder);
void Update(Folder folder);
void Remove(Folder folder);
Task<Folder?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
Task<List<Folder>> GetUserFoldersAsync(Guid userId, CancellationToken cancellationToken);
}
public interface IUserChatSettingsRepository
{
void Add(UserChatSettings settings);
void Update(UserChatSettings settings);
void Remove(UserChatSettings settings);
void RemoveRange(IEnumerable<UserChatSettings> settings);
Task<UserChatSettings?> GetAsync(Guid userId, Guid chatId, CancellationToken cancellationToken);
Task<List<UserChatSettings>> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken);
}
public interface IUserFolderSettingsRepository
{
Task<UserFolderSettings?> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken);
Task UpdateAsync(UserFolderSettings settings, CancellationToken cancellationToken);
Task RemoveByUserIdAsync(Guid userId, CancellationToken cancellationToken);
}
@@ -8,6 +8,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" /> <ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\Messaging\Knot.Contracts.Messaging.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -0,0 +1,11 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Contracts.Messaging.Application.Abstractions;
public interface IChatAccessProvider
{
Task<List<Guid>> GetValidChatIdsForUserAsync(Guid userId, CancellationToken ct);
}
@@ -0,0 +1,6 @@
namespace Knot.Contracts.Messaging.Application.Abstractions;
public interface IMessageNotifier
{
Task NotifyNewMessageAsync(Guid chatId, object messagePayload, CancellationToken cancellationToken);
}
@@ -0,0 +1,21 @@
using Knot.Contracts.Messaging.Domain;
namespace Knot.Contracts.Messaging.Application.Abstractions;
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<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, int limit, CancellationToken cancellationToken);
Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
Task UpdateAsync(Message message, CancellationToken cancellationToken);
Task<List<Message>> GetAllMessagesAsync(CancellationToken cancellationToken);
Task DeleteChatMessagesAsync(Guid chatId, CancellationToken cancellationToken);
Task DeleteUserMessagesAsync(Guid userId, CancellationToken cancellationToken);
Task RemoveAsync(Guid id, CancellationToken cancellationToken);
}
@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Contracts.Messaging.Application.Abstractions;
public record UserStats(int MessageCount, long StorageSize);
public interface IUserStatsService
{
Task<Dictionary<Guid, UserStats>> GetStatsForUsersAsync(IEnumerable<Guid> userIds, CancellationToken ct = default);
Task<long> GetTotalStorageSizeAsync(CancellationToken ct = default);
Task<int> GetCountOrphanedMessagesAsync(HashSet<Guid> activeChatIds, CancellationToken ct = default);
Task<long> GetOrphanedMediaSizeAsync(HashSet<string> validFileIds, CancellationToken ct = default);
}
@@ -0,0 +1,20 @@
using Knot.Shared.Kernel;
namespace Knot.Contracts.Messaging.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) { }
}
@@ -0,0 +1,9 @@
namespace Knot.Contracts.Messaging.Domain;
public interface IMessageReactionRepository
{
Task AddAsync(MessageReaction reaction, CancellationToken cancellationToken);
Task RemoveAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken);
Task<List<MessageReaction>> GetReactionsForMessageAsync(Guid messageId, CancellationToken cancellationToken);
Task<List<MessageReaction>> GetReactionsForMessagesAsync(IEnumerable<Guid> messageIds, CancellationToken cancellationToken);
}
@@ -0,0 +1,9 @@
namespace Knot.Contracts.Messaging.Domain;
public enum MediaType
{
Image,
Video,
Voice,
File
}
@@ -0,0 +1,126 @@
using System;
using System.Collections.Generic;
using Knot.Shared.Kernel;
namespace Knot.Contracts.Messaging.Domain;
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 long SequenceId { get; protected set; }
public void SetSequenceId(long sequenceId) => SequenceId = sequenceId;
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 List<DeletedMessage> _deletedFor = new();
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
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 virtual void Delete() => AddState(MessageState.IsDeleted);
public virtual void Edit(string newContent) { Content = newContent; AddState(MessageState.IsEdited); }
public void DeleteForUser(Guid userId) { if (!_deletedFor.Exists(x => x.UserId == userId)) _deletedFor.Add(new DeletedMessage(Id, userId)); }
}
public class Media
{
public Guid Id { get; set; }
public string Type { get; set; } = string.Empty;
public string? Url { get; set; }
public string? ThumbnailUrl { get; set; }
public long? Size { get; set; }
public int Width { get; set; }
public int Height { get; set; }
public string? FileId { get; set; }
public string? Filename { get; set; }
public string? Duration { get; set; }
}
public class TextMessage : Message
{
public override string Type => "text";
public override string? Content { get; protected set; }
public TextMessage() : base() { }
public TextMessage(Guid id, Guid chatId, Guid senderId, string content, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported = false)
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported) => Content = content;
public TextMessage(Guid id, Guid chatId, Guid senderId, string content, Guid? replyToId, string? quote, Guid? forwardedFromId, DateTime createdAt, bool isImported = false)
: this(id, chatId, senderId, content, replyToId, forwardedFromId, createdAt, isImported) => Quote = quote;
}
public class MediaMessage : Message
{
public override string Type => MediaType.ToString().ToLower();
public override string? Content { get; protected set; }
public string? Caption { get => Content; private set => Content = value; }
public MediaType MediaType { get; private set; }
private 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; }
public MediaMessage(Guid id, Guid chatId, Guid senderId, string mediaType, string? caption, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
: this(id, chatId, senderId, Enum.TryParse<MediaType>(mediaType, true, out var mt) ? mt : MediaType.File, caption, replyToId, forwardedFromId, createdAt, isImported) { }
public void AddMedia(string type, string url, string? filename, long? size) => _media.Add(new Media { Type = type, Url = url, FileId = filename, Size = size });
public override void Edit(string newCaption) => base.Edit(newCaption);
public override void Delete() { Caption = null; base.Delete(); }
}
public class StoryMessage : Message
{
public override string Type => "story";
public override string? Content { get; protected set; }
public override Guid? StoryId { get; }
public string? InternalStoryMediaUrl { get; private set; }
public override string? StoryMediaUrl => InternalStoryMediaUrl;
public override string? StoryMediaType { get; }
public StoryMessage() : base() { }
public StoryMessage(Guid id, Guid chatId, Guid senderId, Guid storyId, string? storyMediaUrl, string? storyMediaType, DateTime createdAt)
: base(id, chatId, senderId, null, null, createdAt, false) { StoryId = storyId; InternalStoryMediaUrl = storyMediaUrl; StoryMediaType = storyMediaType; }
public StoryMessage(Guid id, Guid chatId, Guid senderId, Guid storyId, string? storyMediaUrl, string? storyMediaType, string? content, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
: this(id, chatId, senderId, storyId, storyMediaUrl, storyMediaType, createdAt) { Content = content; ReplyToId = replyToId; ForwardedFromId = forwardedFromId; if (isImported) AddState(MessageState.IsImported); }
}
public class PollMessage : Message
{
public override string Type => "poll";
public override string? Content { get; protected set; }
public List<PollOption> Options { get; } = new();
public List<PollVote> Votes { get; } = new();
public bool IsMultipleChoice { get; set; }
public DateTime? ExpiresAt { get; set; }
public bool IsClosed { get; set; }
public PollMessage() : base() { }
public PollMessage(Guid id, Guid chatId, Guid senderId, string? question, List<string>? options, bool isAnonymous, bool isMultiple, DateTime? expiresAt, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported)
{
Content = question ?? "Poll";
if (options != null) foreach (var opt in options) Options.Add(new PollOption { Text = opt });
IsMultipleChoice = isMultiple;
ExpiresAt = expiresAt;
}
}
public class PollOption { public string Text { get; set; } = string.Empty; public int VoteCount { get; set; } }
public class PollVote { public Guid OptionIndex { get; set; } public Guid UserId { get; set; } public DateTime VotedAt { get; set; } }
@@ -0,0 +1,28 @@
using Knot.Shared.Kernel;
namespace Knot.Contracts.Messaging.Domain;
/// <summary>
/// Доменное событие: сообщение отправлено.
/// </summary>
public sealed record MessageSentDomainEvent(Guid MessageId, Guid ChatId, Guid SenderId, string? Content) : IDomainEvent;
/// <summary>
/// Доменное событие: сообщение удалено.
/// </summary>
public sealed record MessageDeletedDomainEvent(Guid MessageId, Guid ChatId) : IDomainEvent;
/// <summary>
/// Доменное событие: сообщение отредактировано.
/// </summary>
public sealed record MessageEditedDomainEvent(Guid MessageId, Guid ChatId, string NewContent) : IDomainEvent;
/// <summary>
/// Доменное событие: реакция добавлена.
/// </summary>
public sealed record MessageReactionAddedDomainEvent(Guid MessageId, Guid ChatId, Guid UserId, string Emoji) : IDomainEvent;
/// <summary>
/// Доменное событие: реакция удалена.
/// </summary>
public sealed record MessageReactionRemovedDomainEvent(Guid MessageId, Guid ChatId, Guid UserId, string Emoji) : IDomainEvent;
@@ -0,0 +1,25 @@
using System;
using Knot.Shared.Kernel;
namespace Knot.Contracts.Messaging.Domain;
public sealed class MessageReaction : AggregateRoot<Guid>
{
public Guid MessageId { get; private set; }
public Guid UserId { get; private set; }
public string Emoji { get; private set; }
public DateTime CreatedAt { get; private set; }
private MessageReaction() : base(Guid.Empty)
{
Emoji = default!;
}
public MessageReaction(Guid messageId, Guid userId, string emoji) : base(Guid.NewGuid())
{
MessageId = messageId;
UserId = userId;
Emoji = emoji;
CreatedAt = DateTime.UtcNow;
}
}
@@ -0,0 +1,14 @@
namespace Knot.Contracts.Messaging.Domain;
/// <summary>
/// Состояние сообщения
/// </summary>
[Flags]
public enum MessageState
{
None = 0,
IsEdited = 1,
IsDeleted = 2,
IsImported = 4,
IsPinned = 8
}
@@ -0,0 +1,23 @@
using System;
using Knot.Shared.Kernel;
namespace Knot.Contracts.Messaging.Domain;
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;
}
}
@@ -0,0 +1,8 @@
using Knot.Shared.Kernel;
namespace Knot.Contracts.Settings.Domain;
/// <summary>
/// Доменное событие: системные настройки обновлены.
/// </summary>
public sealed record SystemSettingsUpdatedDomainEvent(Application.DTOs.SystemSettingsDto Settings) : IDomainEvent;
@@ -1,18 +1,19 @@
using Knot.Modules.Admin.Application.Admin.DTOs;
using Knot.Modules.Messaging.Domain;
using System; using System;
using System.Linq;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediatR; using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Shared.Kernel; using Knot.Contracts.Messaging.Domain;
using Knot.Shared.Kernel.Storage; using Knot.Modules.Admin.Application.Admin.DTOs;
using MongoDB.Driver;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Auth.Infrastructure.Persistence; using Knot.Modules.Auth.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.Persistence; using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Stories.Domain; using Knot.Modules.Stories.Domain;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using MediatR;
using Microsoft.EntityFrameworkCore;
using MongoDB.Driver;
namespace Knot.Modules.Admin.Application.Admin.Commands; namespace Knot.Modules.Admin.Application.Admin.Commands;
@@ -37,7 +38,7 @@ internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand,
public async Task<Result<MessageResponse>> Handle(CleanRunCommand request, CancellationToken cancellationToken) public async Task<Result<MessageResponse>> Handle(CleanRunCommand request, CancellationToken cancellationToken)
{ {
try try
{ {
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken); var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken);
var activeChatIds = allChats.Select(c => c.Id).ToHashSet(); var activeChatIds = allChats.Select(c => c.Id).ToHashSet();
@@ -55,7 +56,7 @@ internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand,
var allMinioFiles = (await _fileStorage.ListFilesAsync()).ToList(); var allMinioFiles = (await _fileStorage.ListFilesAsync()).ToList();
var allUsers = await _identityDb.Users.AsNoTracking().ToListAsync(cancellationToken); var allUsers = await _identityDb.Users.AsNoTracking().ToListAsync(cancellationToken);
var allStories = await _stories.Find(_ => true).ToListAsync(cancellationToken); var allStories = await _stories.Find(_ => true).ToListAsync(cancellationToken);
var validUrls = new HashSet<string>(); var validUrls = new HashSet<string>();
@@ -73,7 +74,7 @@ internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand,
var activeUserUrls = allUsers var activeUserUrls = allUsers
.Where(u => !string.IsNullOrEmpty(u.Avatar)) .Where(u => !string.IsNullOrEmpty(u.Avatar))
.Select(u => u.Avatar!); .Select(u => u.Avatar!);
var activeStoryUrls = allStories var activeStoryUrls = allStories
.Where(s => !string.IsNullOrEmpty(s.MediaUrl)) .Where(s => !string.IsNullOrEmpty(s.MediaUrl))
.Select(s => s.MediaUrl!); .Select(s => s.MediaUrl!);
@@ -1,21 +1,22 @@
using Knot.Modules.Admin.Application.Admin.DTOs;
using Knot.Modules.Messaging.Domain;
using System; using System;
using System.Linq;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediatR; using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Shared.Kernel; using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Shared.Kernel.Storage; using Knot.Contracts.Messaging.Domain;
using MongoDB.Driver; using Knot.Modules.Admin.Application.Admin.DTOs;
using MongoDB.Bson;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Auth.Infrastructure.Persistence; using Knot.Modules.Auth.Infrastructure.Persistence;
using Knot.Modules.Auth.Application.Users;
using Knot.Modules.Conversations.Infrastructure.Persistence; using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Stories.Domain; using Knot.Modules.Stories.Domain;
using Knot.Modules.Auth.Application.Auth.DTOs; using Knot.Shared.Kernel;
using Knot.Modules.Auth.Application.Users; using Knot.Shared.Kernel.Storage;
using MediatR;
using Microsoft.EntityFrameworkCore;
using MongoDB.Bson;
using MongoDB.Driver;
namespace Knot.Modules.Admin.Application.Admin.Queries; namespace Knot.Modules.Admin.Application.Admin.Queries;
@@ -40,14 +41,14 @@ internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery,
public async Task<Result<CleanupDryRunResultDto>> Handle(CleanDryRunQuery request, CancellationToken ct) public async Task<Result<CleanupDryRunResultDto>> Handle(CleanDryRunQuery request, CancellationToken ct)
{ {
try try
{ {
// 1. Получаем ID активных чатов (SQL) // 1. Получаем ID активных чатов (SQL)
var activeChats = await _chatsDbContext.Chats var activeChats = await _chatsDbContext.Chats
.AsNoTracking() .AsNoTracking()
.Select(c => new { c.Id, c.Avatar }) .Select(c => new { c.Id, c.Avatar })
.ToListAsync(ct); .ToListAsync(ct);
var activeChatIds = activeChats.Select(c => c.Id).ToHashSet(); var activeChatIds = activeChats.Select(c => c.Id).ToHashSet();
// 2. Считаем сообщения подлежащие удалению (MongoDB) // 2. Считаем сообщения подлежащие удалению (MongoDB)
@@ -62,7 +63,7 @@ internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery,
// Аватары чатов и пользователей // Аватары чатов и пользователей
foreach (var chat in activeChats) AddFileIdIfValid(chat.Avatar, validFileIds); foreach (var chat in activeChats) AddFileIdIfValid(chat.Avatar, validFileIds);
var userAvatars = await _identityDb.Users.AsNoTracking() var userAvatars = await _identityDb.Users.AsNoTracking()
.Where(u => !string.IsNullOrEmpty(u.Avatar)) .Where(u => !string.IsNullOrEmpty(u.Avatar))
.Select(u => u.Avatar).ToListAsync(ct); .Select(u => u.Avatar).ToListAsync(ct);
@@ -78,9 +79,9 @@ internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery,
Builders<Message>.Filter.BitsAllClear(m => m.State, (long)MessageState.IsDeleted), Builders<Message>.Filter.BitsAllClear(m => m.State, (long)MessageState.IsDeleted),
Builders<Message>.Filter.In(m => m.ChatId, activeChatIds) Builders<Message>.Filter.In(m => m.ChatId, activeChatIds)
); );
var projection = Builders<Message>.Projection.Include("Media"); var projection = Builders<Message>.Projection.Include("Media");
using (var cursor = await _messages.Find(activeFilter).Project(projection).ToCursorAsync(ct)) using (var cursor = await _messages.Find(activeFilter).Project(projection).ToCursorAsync(ct))
{ {
while (await cursor.MoveNextAsync(ct)) while (await cursor.MoveNextAsync(ct))
@@ -10,11 +10,16 @@
<ProjectReference Include="..\..\Contracts\Auth\Knot.Contracts.Auth.csproj" /> <ProjectReference Include="..\..\Contracts\Auth\Knot.Contracts.Auth.csproj" />
<ProjectReference Include="..\..\Contracts\Profiles\Knot.Contracts.Profiles.csproj" /> <ProjectReference Include="..\..\Contracts\Profiles\Knot.Contracts.Profiles.csproj" />
<ProjectReference Include="..\..\Contracts\Conversations\Knot.Contracts.Conversations.csproj" /> <ProjectReference Include="..\..\Contracts\Conversations\Knot.Contracts.Conversations.csproj" />
<ProjectReference Include="..\..\Contracts\Messaging\Knot.Contracts.Messaging.csproj" />
<ProjectReference Include="..\..\Contracts\Settings\Knot.Contracts.Settings.csproj" /> <ProjectReference Include="..\..\Contracts\Settings\Knot.Contracts.Settings.csproj" />
<ProjectReference Include="..\..\Contracts\Stories\Knot.Contracts.Stories.csproj" />
<ProjectReference Include="..\..\Contracts\Klipy\Knot.Contracts.Klipy.csproj" />
<!-- Admin needs direct module access for cleanup operations -->
<ProjectReference Include="..\Auth\Knot.Modules.Auth.csproj" /> <ProjectReference Include="..\Auth\Knot.Modules.Auth.csproj" />
<ProjectReference Include="..\Conversations\Knot.Modules.Conversations.csproj" /> <ProjectReference Include="..\Conversations\Knot.Modules.Conversations.csproj" />
<ProjectReference Include="..\Stories\Knot.Modules.Stories.csproj" /> <ProjectReference Include="..\Stories\Knot.Modules.Stories.csproj" />
<ProjectReference Include="..\Klipy\Knot.Modules.Klipy.csproj" /> <ProjectReference Include="..\Klipy\Knot.Modules.Klipy.csproj" />
<ProjectReference Include="..\Messaging\Knot.Modules.Messaging.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Carter" Version="10.0.0" /> <PackageReference Include="Carter" Version="10.0.0" />
@@ -24,4 +29,4 @@
<_Parameter1>Knot.Modules.Admin.UnitTests</_Parameter1> <_Parameter1>Knot.Modules.Admin.UnitTests</_Parameter1>
</AssemblyAttribute> </AssemblyAttribute>
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,17 +1,17 @@
using Knot.Contracts.Auth.Domain; using BCrypt.Net;
using Knot.Contracts.Auth.Application.Abstractions; using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs; using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Contracts.Auth.Domain;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using BCrypt.Net;
namespace Knot.Modules.Auth.Application.Users.Login; namespace Knot.Modules.Auth.Application.Users.Login;
/// <summary> /// <summary>
/// Êîìàíäà äëÿ âõîäà ïîëüçîâàòåëÿ. Âîçâðàùàåò AuthResponseDto. /// . AuthResponseDto.
/// </summary> /// </summary>
public sealed record LoginUserCommand(string Username, string Password) : ICommand<AuthResponseDto>; public sealed record LoginUserCommand(string Username, string Password) : ICommand<AuthResponseDto>;
internal sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand, AuthResponseDto> public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand, AuthResponseDto>
{ {
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly IJwtTokenProvider _tokenProvider; private readonly IJwtTokenProvider _tokenProvider;
@@ -1,15 +1,15 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediatR; using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Shared.Kernel; using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.DTOs; using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Domain; using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions; using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Conversations.Application.Chats.GetChatById; namespace Knot.Modules.Conversations.Application.Chats.GetChatById;
@@ -50,8 +50,8 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery,
} }
var latestMessage = await _messageRepository.GetLatestChatMessageAsync(chat.Id, cancellationToken); var latestMessage = await _messageRepository.GetLatestChatMessageAsync(chat.Id, cancellationToken);
var latestReactions = latestMessage != null var latestReactions = latestMessage != null
? await _reactionRepository.GetReactionsForMessageAsync(latestMessage.Id, cancellationToken) ? await _reactionRepository.GetReactionsForMessageAsync(latestMessage.Id, cancellationToken)
: new List<MessageReaction>(); : new List<MessageReaction>();
if (latestMessage != null) if (latestMessage != null)
@@ -1,15 +1,15 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediatR; using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Shared.Kernel; using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.DTOs; using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Domain; using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions; using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Conversations.Application.Chats.GetChats; namespace Knot.Modules.Conversations.Application.Chats.GetChats;
@@ -39,12 +39,12 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
foreach (var chat in userChats) foreach (var chat in userChats)
{ {
var latestMessage = await _messageRepository.GetLatestChatMessageAsync(chat.Id, cancellationToken); var latestMessage = await _messageRepository.GetLatestChatMessageAsync(chat.Id, cancellationToken);
var latestReactions = latestMessage != null var latestReactions = latestMessage != null
? await _reactionRepository.GetReactionsForMessageAsync(latestMessage.Id, cancellationToken) ? await _reactionRepository.GetReactionsForMessageAsync(latestMessage.Id, cancellationToken)
: new List<MessageReaction>(); : new List<MessageReaction>();
var userIdsToFetch = new HashSet<Guid>(); var userIdsToFetch = new HashSet<Guid>();
foreach (var member in chat.Members) foreach (var member in chat.Members)
{ {
userIdsToFetch.Add(member.UserId); userIdsToFetch.Add(member.UserId);
@@ -1,10 +1,10 @@
using MediatR; using global::Knot.Modules.Conversations.Application.Abstractions;
using Microsoft.AspNetCore.SignalR;
using global::Knot.Modules.Conversations.Domain; using global::Knot.Modules.Conversations.Domain;
using global::Knot.Modules.Conversations.Infrastructure.SignalR; using global::Knot.Modules.Conversations.Infrastructure.SignalR;
using global::Knot.Shared.Kernel; using global::Knot.Shared.Kernel;
using global::Knot.Modules.Conversations.Application.Abstractions; using MediatR;
using MessagingMessageRepository = Knot.Modules.Messaging.Domain.IMessageRepository; using Microsoft.AspNetCore.SignalR;
using MessagingMessageRepository = Knot.Contracts.Messaging.Application.Abstractions.IMessageRepository;
namespace Knot.Modules.Conversations.Application.Messages.Delete; namespace Knot.Modules.Conversations.Application.Messages.Delete;
@@ -1,16 +1,15 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediatR; using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Shared.Kernel; using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.DTOs; using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Domain; using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Application.Abstractions; using MediatR;
namespace Knot.Modules.Conversations.Application.Messages.GetMessages; namespace Knot.Modules.Conversations.Application.Messages.GetMessages;
@@ -74,7 +73,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
} }
var senders = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken); var senders = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
var messageIds = filteredMessages.Select(m => m.Id).ToList(); var messageIds = filteredMessages.Select(m => m.Id).ToList();
var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken); var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken);
var reactionsByMessage = allReactions.GroupBy(r => r.MessageId).ToDictionary(g => g.Key, g => g.ToList()); var reactionsByMessage = allReactions.GroupBy(r => r.MessageId).ToDictionary(g => g.Key, g => g.ToList());
@@ -1,17 +1,16 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediatR; using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Shared.Kernel; using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.DTOs; using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Domain; using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Application.Abstractions; using MediatR;
namespace Knot.Modules.Conversations.Application.Messages.GetSharedMedia; namespace Knot.Modules.Conversations.Application.Messages.GetSharedMedia;
@@ -1,12 +1,12 @@
using Knot.Modules.Messaging.Application.Abstractions; using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain; using Knot.Contracts.Messaging.Domain;
using MediatR;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Knot.Modules.Conversations.Application.Abstractions; using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Domain; using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Infrastructure.SignalR; using Knot.Modules.Conversations.Infrastructure.SignalR;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using MediatR;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace Knot.Modules.Conversations.Application.Messages.React; namespace Knot.Modules.Conversations.Application.Messages.React;
@@ -1,12 +1,12 @@
using Knot.Modules.Messaging.Application.Abstractions; using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain; using Knot.Contracts.Messaging.Domain;
using MediatR;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Knot.Modules.Conversations.Application.Abstractions; using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Domain; using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Infrastructure.SignalR; using Knot.Modules.Conversations.Infrastructure.SignalR;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using MediatR;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace Knot.Modules.Conversations.Application.Messages.React; namespace Knot.Modules.Conversations.Application.Messages.React;
@@ -1,14 +1,14 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediatR; using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Shared.Kernel; using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.DTOs; using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Domain; using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Conversations.Application.Messages.SearchMessages; namespace Knot.Modules.Conversations.Application.Messages.SearchMessages;
@@ -36,7 +36,7 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
userIds.AddRange(messages.Where(message => message.ForwardedFromId.HasValue).Select(message => message.ForwardedFromId!.Value)); userIds.AddRange(messages.Where(message => message.ForwardedFromId.HasValue).Select(message => message.ForwardedFromId!.Value));
var senders = await _userProvider.GetUsersInfoAsync(userIds.Distinct(), cancellationToken); var senders = await _userProvider.GetUsersInfoAsync(userIds.Distinct(), cancellationToken);
var messageIds = messages.Select(m => m.Id).ToList(); var messageIds = messages.Select(m => m.Id).ToList();
var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken); var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken);
var reactionsByMessage = allReactions.GroupBy(r => r.MessageId).ToDictionary(g => g.Key, g => g.ToList()); var reactionsByMessage = allReactions.GroupBy(r => r.MessageId).ToDictionary(g => g.Key, g => g.ToList());
@@ -54,7 +54,7 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
message.CreatedAt, message.CreatedAt,
message.SequenceId, message.SequenceId,
message.ForwardedFromId, message.ForwardedFromId,
null, null,
message.StoryId, message.StoryId,
message.StoryMediaUrl, message.StoryMediaUrl,
message.StoryMediaType, message.StoryMediaType,
@@ -1,14 +1,14 @@
using Knot.Modules.Messaging.Application.Abstractions; using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain; using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Contracts.Settings.Application.Abstractions; using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Modules.Conversations.Application.Abstractions; using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
namespace Knot.Modules.Conversations.Application.Messages.Send; namespace Knot.Modules.Conversations.Application.Messages.Send;
/// <summary> /// <summary>
/// Êîìàíäà äëÿ îòïðàâêè ñîîáùåíèÿ â ÷àò. /// .
/// </summary> /// </summary>
public record AttachmentRequest(string Type, string Url, string? FileName, long? FileSize); public record AttachmentRequest(string Type, string Url, string? FileName, long? FileSize);
@@ -53,37 +53,47 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
public async Task<Result<Guid>> Handle(SendMessageCommand request, CancellationToken cancellationToken) public async Task<Result<Guid>> Handle(SendMessageCommand request, CancellationToken cancellationToken)
{ {
// 1. Ïðîâåðÿåì ñóùåñòâîâàíèå ÷àòà // 1.
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken); var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat is null) if (chat is null)
{ {
return Result.Failure<Guid>(ChatErrors.ChatsNotFound); return Result.Failure<Guid>(ChatErrors.ChatsNotFound);
} }
// 2. Ïðîâåðÿåì, ÿâëÿåòñÿ ëè îòïðàâèòåëü ó÷àñòíèêîì // 2. ,
if (!chat.Members.Any(m => m.UserId == request.SenderId)) if (!chat.Members.Any(m => m.UserId == request.SenderId))
{ {
return Result.Failure<Guid>(ChatErrors.ChatsForbidden); return Result.Failure<Guid>(ChatErrors.ChatsForbidden);
} }
// 3. Ñîçäàåì ñîîáùåíèå // 3.
Message message; Message message;
if (request.Type == "story_reply" || request.Type == "story_reaction") if (request.Type == "story_reply" || request.Type == "story_reaction")
{ {
if (!_messagesSettings.Current.AllowMedia) return Result.Failure<Guid>(ChatErrors.MediaDisabled); if (!_messagesSettings.Current.AllowMedia) return Result.Failure<Guid>(ChatErrors.MediaDisabled);
var parsedStoryMediaType = Enum.TryParse<MediaType>(request.StoryMediaType, true, out var sTypeEnum) ? sTypeEnum : MediaType.Image; var parsedStoryMediaType = Enum.TryParse<MediaType>(request.StoryMediaType, true, out var sTypeEnum) ? sTypeEnum : MediaType.Image;
message = new StoryMessage( message = new StoryMessage(
Guid.NewGuid(), Guid.NewGuid(),
request.ChatId,
request.SenderId, request.ChatId,
request.StoryId ?? Guid.Empty,
request.StoryMediaUrl ?? string.Empty, request.SenderId,
parsedStoryMediaType,
request.Content, request.StoryId ?? Guid.Empty,
request.ReplyToId,
request.ForwardedFromId, request.StoryMediaUrl ?? string.Empty,
DateTime.UtcNow, request.StoryMediaType,
request.Content,
request.ReplyToId,
request.ForwardedFromId,
DateTime.UtcNow,
false); false);
} }
else if (request.Attachments != null && request.Attachments.Any()) else if (request.Attachments != null && request.Attachments.Any())
@@ -92,22 +102,31 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
var firstAtt = request.Attachments.First(); var firstAtt = request.Attachments.First();
var parsedType = Enum.TryParse<MediaType>(firstAtt.Type, true, out var mTypeEnum) ? mTypeEnum : MediaType.File; var parsedType = Enum.TryParse<MediaType>(firstAtt.Type, true, out var mTypeEnum) ? mTypeEnum : MediaType.File;
message = new MediaMessage( message = new MediaMessage(
Guid.NewGuid(), Guid.NewGuid(),
request.ChatId,
request.SenderId, request.ChatId,
parsedType,
request.Content, request.SenderId,
request.ReplyToId, parsedType,
request.ForwardedFromId,
DateTime.UtcNow, request.Content,
request.ReplyToId,
request.ForwardedFromId,
DateTime.UtcNow,
false); false);
foreach (var att in request.Attachments) foreach (var att in request.Attachments)
{ {
var pType = Enum.TryParse<MediaType>(att.Type, true, out var tEnum) ? tEnum : MediaType.File; var pType = Enum.TryParse<MediaType>(att.Type, true, out var tEnum) ? tEnum : MediaType.File;
((MediaMessage)message).AddMedia(pType, att.Url, att.FileName, att.FileSize); ((MediaMessage)message).AddMedia(pType.ToString().ToLower(), att.Url, att.FileName, att.FileSize);
} }
} }
else if (request.Type == "poll") else if (request.Type == "poll")
@@ -131,18 +150,26 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
else else
{ {
message = new TextMessage( message = new TextMessage(
Guid.NewGuid(), Guid.NewGuid(),
request.ChatId,
request.SenderId, request.ChatId,
request.Content ?? string.Empty,
request.ReplyToId, request.SenderId,
request.Quote,
request.ForwardedFromId, request.Content ?? string.Empty,
DateTime.UtcNow,
request.ReplyToId,
request.Quote,
request.ForwardedFromId,
DateTime.UtcNow,
false); false);
} }
// 4. Ïîñëåäîâàòåëüíîñòü ñîîáùåíèé High-Water Mark // 4. High-Water Mark
chat.IncrementSequenceId(); chat.IncrementSequenceId();
message.SetSequenceId(chat.LastMessageSequenceId); message.SetSequenceId(chat.LastMessageSequenceId);
@@ -150,15 +177,19 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
senderMember.UpdateReadCursor(message.Id, message.SequenceId); senderMember.UpdateReadCursor(message.Id, message.SequenceId);
senderMember.UpdateDeliveredCursor(message.Id); senderMember.UpdateDeliveredCursor(message.Id);
// 5. Ñîõðàíÿåì // 5.
_messageRepository.Add(message); _messageRepository.Add(message);
await _unitOfWork.SaveChangesAsync(cancellationToken); await _unitOfWork.SaveChangesAsync(cancellationToken);
await _mediator.Publish(new MessageSentDomainEvent( await _mediator.Publish(new MessageSentDomainEvent(
message.Id, message.Id,
message.ChatId,
message.SenderId, message.ChatId,
message.Content),
message.SenderId,
message.Content),
cancellationToken); cancellationToken);
return Result.Success(message.Id); return Result.Success(message.Id);
@@ -1,12 +1,12 @@
using Knot.Contracts.Auth.Domain; using System.Text.RegularExpressions;
using Knot.Contracts.Auth.Application.Abstractions; using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Domain;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Domain; using Knot.Modules.Conversations.Domain;
using Knot.Modules.Messaging.Domain;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage; using Knot.Shared.Kernel.Storage;
using MediatR; using MediatR;
using System.Text.RegularExpressions;
using Knot.Modules.Conversations.Application.Abstractions;
namespace Knot.Modules.Conversations.Application.Users.Commands.DeleteUser; namespace Knot.Modules.Conversations.Application.Users.Commands.DeleteUser;
@@ -17,7 +17,7 @@ internal sealed class DeleteUserCommandHandler : ICommandHandler<DeleteUserComma
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly IUserChatSettingsRepository _userChatSettingsRepository; private readonly IUserChatSettingsRepository _userChatSettingsRepository;
private readonly IUserFolderSettingsRepository _userFolderSettingsRepository; private readonly IUserFolderSettingsRepository _userFolderSettingsRepository;
private readonly Knot.Modules.Messaging.Domain.IMessageRepository _messageRepository; private readonly Knot.Contracts.Messaging.Application.Abstractions.IMessageRepository _messageRepository;
private readonly IFileStorageService _fileStorage; private readonly IFileStorageService _fileStorage;
private readonly IChatsUnitOfWork _unitOfWork; private readonly IChatsUnitOfWork _unitOfWork;
private readonly IAuthUnitOfWork _authUnitOfWork; private readonly IAuthUnitOfWork _authUnitOfWork;
@@ -26,7 +26,7 @@ internal sealed class DeleteUserCommandHandler : ICommandHandler<DeleteUserComma
IUserRepository userRepository, IUserRepository userRepository,
IUserChatSettingsRepository userChatSettingsRepository, IUserChatSettingsRepository userChatSettingsRepository,
IUserFolderSettingsRepository userFolderSettingsRepository, IUserFolderSettingsRepository userFolderSettingsRepository,
Knot.Modules.Messaging.Domain.IMessageRepository messageRepository, Knot.Contracts.Messaging.Application.Abstractions.IMessageRepository messageRepository,
IFileStorageService fileStorage, IFileStorageService fileStorage,
IChatsUnitOfWork unitOfWork, IChatsUnitOfWork unitOfWork,
IAuthUnitOfWork authUnitOfWork) IAuthUnitOfWork authUnitOfWork)
@@ -55,9 +55,9 @@ internal sealed class DeleteUserCommandHandler : ICommandHandler<DeleteUserComma
{ {
foreach (var media in mediaMsg.Media) foreach (var media in mediaMsg.Media)
{ {
bool isUsedElsewhere = allMessages.Any(m => m.Id != msg.Id && bool isUsedElsewhere = allMessages.Any(m => m.Id != msg.Id &&
m is MediaMessage mm && mm.Media.Any(ame => ame.Url == media.Url)); m is MediaMessage mm && mm.Media.Any(ame => ame.Url == media.Url));
if (!isUsedElsewhere) if (!isUsedElsewhere)
{ {
var fileId = ExtractFileId(media.Url); var fileId = ExtractFileId(media.Url);
@@ -1,27 +1,22 @@
using Microsoft.Extensions.DependencyInjection; using Knot.Contracts.Messaging.Application.Abstractions;
using Microsoft.Extensions.Configuration; using Knot.Contracts.Messaging.Domain;
using Microsoft.EntityFrameworkCore; using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Domain; using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Infrastructure.Persistence; using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.Persistence.Mongo; using Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Application.Abstractions; using Microsoft.EntityFrameworkCore;
using Knot.Modules.Messaging.Domain; using Microsoft.Extensions.Configuration;
using Knot.Modules.Messaging.Infrastructure.Persistence; using Microsoft.Extensions.DependencyInjection;
namespace Knot.Modules.Conversations; namespace Knot.Modules.Conversations;
public static class DependencyInjection public static class DependencyInjection
{ {
/// <summary>
/// ╨а╨╡╨│╨╕╤Б╤В╤А╨░╤Ж╨╕╤П ╤Б╨╡╤А╨▓╨╕╤Б╨╛╨▓ ╨╝╨╛╨┤╤Г╨╗╤П Chats.
/// </summary>
public static IServiceCollection AddConversationsModule( public static IServiceCollection AddConversationsModule(
this IServiceCollection services, this IServiceCollection services,
IConfiguration configuration) IConfiguration configuration)
{ {
// ╨Э╨░╤Б╤В╤А╨╛╨╣╨║╨░ ╨▒╨░╨╖╤Л ╨┤╨░╨╜╨╜╤Л╤Е
string connectionString = configuration.GetConnectionString("DefaultConnection")!; string connectionString = configuration.GetConnectionString("DefaultConnection")!;
services.AddDbContext<ChatsDbContext>(options => services.AddDbContext<ChatsDbContext>(options =>
@@ -29,7 +24,7 @@ public static class DependencyInjection
// MongoDB Setup for Messages // MongoDB Setup for Messages
ConversationsMongoDbMapConfigurator.Configure(); ConversationsMongoDbMapConfigurator.Configure();
var mongoConnectionString = configuration.GetConnectionString("MongoConnection") ?? "mongodb://localhost:27017"; var mongoConnectionString = configuration.GetConnectionString("MongoConnection") ?? "mongodb://localhost:27017";
// Registration // Registration
@@ -38,17 +33,17 @@ public static class DependencyInjection
services.AddScoped<IFolderRepository, FolderRepository>(); services.AddScoped<IFolderRepository, FolderRepository>();
services.AddScoped<IUserChatSettingsRepository, UserChatSettingsRepository>(); services.AddScoped<IUserChatSettingsRepository, UserChatSettingsRepository>();
services.AddScoped<IUserFolderSettingsRepository, UserFolderSettingsRepository>(); services.AddScoped<IUserFolderSettingsRepository, UserFolderSettingsRepository>();
// Messaging Repository registration (might be redundant if already in Messaging module, but needed for specific commands in Conversations) // Messaging Repository - registered in Messaging module
services.AddScoped<IMessageRepository, MessageRepository>(); // services.AddScoped<IMessageRepository, MessageRepository>();
// MediatR // MediatR
services.AddMediatR(config => services.AddMediatR(config =>
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly)); config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
services.AddScoped<Knot.Modules.Messaging.Application.Abstractions.IChatAccessProvider, Knot.Modules.Conversations.Infrastructure.Services.ChatAccessProvider>(); services.AddScoped<Knot.Contracts.Messaging.Application.Abstractions.IChatAccessProvider, Knot.Modules.Conversations.Infrastructure.Services.ChatAccessProvider>();
services.AddScoped<Knot.Modules.Messaging.Application.Abstractions.IMessageNotifier, Knot.Modules.Conversations.Infrastructure.SignalR.MessageNotifier>(); services.AddScoped<Knot.Contracts.Messaging.Application.Abstractions.IMessageNotifier, Knot.Modules.Conversations.Infrastructure.SignalR.MessageNotifier>();
services.AddScoped<IUserStatusService, Knot.Modules.Conversations.Infrastructure.Services.UserStatusService>(); services.AddScoped<IUserStatusService, Knot.Modules.Conversations.Infrastructure.Services.UserStatusService>();
return services; return services;
} }
} }
@@ -1,11 +1,12 @@
namespace Knot.Modules.Conversations.Infrastructure.Services; namespace Knot.Modules.Conversations.Infrastructure.Services;
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
public class ChatAccessProvider : IChatAccessProvider { private readonly ChatsDbContext _db; public ChatAccessProvider(ChatsDbContext db) { _db = db; } public Task<List<Guid>> GetValidChatIdsForUserAsync(Guid userId, CancellationToken ct) { return _db.Chats.Where(c => c.Members.Any(m => m.UserId == userId)).Select(c => c.Id).ToListAsync(ct); } } public class ChatAccessProvider : IChatAccessProvider { private readonly ChatsDbContext _db; public ChatAccessProvider(ChatsDbContext db) { _db = db; } public Task<List<Guid>> GetValidChatIdsForUserAsync(Guid userId, CancellationToken ct) { return _db.Chats.Where(c => c.Members.Any(m => m.UserId == userId)).Select(c => c.Id).ToListAsync(ct); } }
@@ -1,7 +1,8 @@
namespace Knot.Modules.Conversations.Infrastructure.SignalR; namespace Knot.Modules.Conversations.Infrastructure.SignalR;
using Knot.Modules.Messaging.Application.Abstractions;
using Microsoft.AspNetCore.SignalR;
using System; using System;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Knot.Contracts.Messaging.Application.Abstractions;
using Microsoft.AspNetCore.SignalR;
public class MessageNotifier : IMessageNotifier { private readonly IHubContext<ChatHub> _hubContext; public MessageNotifier(IHubContext<ChatHub> hubContext) { _hubContext = hubContext; } public Task NotifyNewMessageAsync(Guid chatId, object messagePayload, CancellationToken cancellationToken) { return _hubContext.Clients.Group(chatId.ToString()).SendAsync("new_message", messagePayload, cancellationToken); } } public class MessageNotifier : IMessageNotifier { private readonly IHubContext<ChatHub> _hubContext; public MessageNotifier(IHubContext<ChatHub> hubContext) { _hubContext = hubContext; } public Task NotifyNewMessageAsync(Guid chatId, object messagePayload, CancellationToken cancellationToken) { return _hubContext.Clients.Group(chatId.ToString()).SendAsync("new_message", messagePayload, cancellationToken); } }
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
@@ -10,8 +9,9 @@
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" /> <ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\..\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj" /> <ProjectReference Include="..\..\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj" />
<ProjectReference Include="..\..\Contracts\Settings\Knot.Contracts.Settings.csproj" /> <ProjectReference Include="..\..\Contracts\Settings\Knot.Contracts.Settings.csproj" />
<ProjectReference Include="..\Messaging\Knot.Modules.Messaging.csproj" /> <ProjectReference Include="..\..\Contracts\Messaging\Knot.Contracts.Messaging.csproj" />
<ProjectReference Include="..\Auth\Knot.Modules.Auth.csproj" /> <ProjectReference Include="..\..\Contracts\Conversations\Knot.Contracts.Conversations.csproj" />
<ProjectReference Include="..\..\Contracts\Auth\Knot.Contracts.Auth.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -5,15 +5,15 @@ using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediatR;
using Knot.Modules.Federation.Application.Abstractions;
using Knot.Shared.Kernel;
using Knot.Contracts.Auth.Domain; using Knot.Contracts.Auth.Domain;
using Knot.Modules.Messaging.Application.Abstractions; using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain; using Knot.Contracts.Messaging.Domain;
using Knot.Contracts.Settings.Application.Abstractions; using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Settings.Application.DTOs; using Knot.Contracts.Settings.Application.DTOs;
using Knot.Modules.Federation.Application.Abstractions;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Constants; using Knot.Shared.Kernel.Constants;
using MediatR;
namespace Host.Application.Federation.Commands; namespace Host.Application.Federation.Commands;
@@ -29,7 +29,8 @@ internal sealed class InboundFederationCommandHandler : ICommandHandler<InboundF
private readonly IMediator _mediator; private readonly IMediator _mediator;
public InboundFederationCommandHandler( public InboundFederationCommandHandler(
ISettingsService settingsService, ISettingsService settingsService,
IMessageRepository messageRepository, IMessageRepository messageRepository,
IUserRepository userRepository, IUserRepository userRepository,
IMessageReactionRepository reactionRepository, IMessageReactionRepository reactionRepository,
@@ -52,7 +53,7 @@ internal sealed class InboundFederationCommandHandler : ICommandHandler<InboundF
var packet = request.Packet; var packet = request.Packet;
// 1. Ïðîâåðÿåì ïîäïèñü îòïðàâèòåëÿ // 1.
var senderConfig = settings.Federation.AllowedDomains.FirstOrDefault(d => d.IsEnabled && d.Domain.Equals(packet.SenderDomain, StringComparison.OrdinalIgnoreCase)); var senderConfig = settings.Federation.AllowedDomains.FirstOrDefault(d => d.IsEnabled && d.Domain.Equals(packet.SenderDomain, StringComparison.OrdinalIgnoreCase));
if (senderConfig == null || string.IsNullOrEmpty(senderConfig.PublicKey)) if (senderConfig == null || string.IsNullOrEmpty(senderConfig.PublicKey))
return Result.Failure(new Error("Federation.SenderNotAllowed", "Sender domain not in allowlist.")); return Result.Failure(new Error("Federation.SenderNotAllowed", "Sender domain not in allowlist."));
@@ -67,22 +68,23 @@ internal sealed class InboundFederationCommandHandler : ICommandHandler<InboundF
} }
} }
// 2. Ðàñøèôðîâûâàåì IV ñâîèì Private Key // 2. IV Private Key
byte[] aesKey; byte[] aesKey;
byte[] aesIv; byte[] aesIv;
using (var rsaDecrypt = RSA.Create()) using (var rsaDecrypt = RSA.Create())
{ {
rsaDecrypt.ImportPkcs8PrivateKey(Convert.FromBase64String(settings.Federation.PrivateKey!), out _); rsaDecrypt.ImportPkcs8PrivateKey(Convert.FromBase64String(settings.Federation.PrivateKey!), out _);
var decryptedKeys = rsaDecrypt.Decrypt(Convert.FromBase64String(packet.EncryptedIV), RSAEncryptionPadding.Pkcs1); var decryptedKeys = rsaDecrypt.Decrypt(Convert.FromBase64String(packet.EncryptedIV), RSAEncryptionPadding.Pkcs1);
// AES-256 Key (32 bytes) + IV (16 bytes) // AES-256 Key (32 bytes) + IV (16 bytes)
aesKey = new byte[32]; aesKey = new byte[32];
aesIv = new byte[16]; aesIv = new byte[16];
Buffer.BlockCopy(decryptedKeys, 0, aesKey, 0, 32); Buffer.BlockCopy(decryptedKeys, 0, aesKey, 0, 32);
Buffer.BlockCopy(decryptedKeys, 32, aesIv, 0, 16); Buffer.BlockCopy(decryptedKeys, 32, aesIv, 0, 16);
} }
// 3. Ðàñøèôðîâûâàåì ñàìî ñîîáùåíèå (AES-256) // 3. (AES-256)
string plainText; string plainText;
using (var aes = Aes.Create()) using (var aes = Aes.Create())
{ {
@@ -96,7 +98,7 @@ internal sealed class InboundFederationCommandHandler : ICommandHandler<InboundF
} }
} }
// 4. Ëîãèêà îáðàáîòêè òèïîâ ñîîáùåíèé // 4.
if (packet.Metadata.MessageType == "sync_capabilities") if (packet.Metadata.MessageType == "sync_capabilities")
{ {
var capabilities = JsonSerializer.Deserialize<RemoteCapabilities>(plainText); var capabilities = JsonSerializer.Deserialize<RemoteCapabilities>(plainText);
@@ -112,14 +114,16 @@ internal sealed class InboundFederationCommandHandler : ICommandHandler<InboundF
{ {
var statusData = JsonSerializer.Deserialize<JsonElement>(plainText); var statusData = JsonSerializer.Deserialize<JsonElement>(plainText);
var isOnline = statusData.GetProperty("IsOnline").GetBoolean(); var isOnline = statusData.GetProperty("IsOnline").GetBoolean();
var user = await _userRepository.GetByIdAsync(packet.Metadata.SenderId, cancellationToken); var user = await _userRepository.GetByIdAsync(packet.Metadata.SenderId, cancellationToken);
if (user != null && user.IsExternal) if (user != null && user.IsExternal)
{ {
user.IsOnline = isOnline; user.IsOnline = isOnline;
await _userRepository.UpdateAsync(user, cancellationToken); await _userRepository.UpdateAsync(user, cancellationToken);
// Óâåäîìëÿåì ëîêàëüíûõ ïîëüçîâàòåëåé ÷åðåç SignalR // SignalR
await _notifier.NotifyNewMessageAsync(Guid.Empty, new { type = "presence_update", userId = user.Id, isOnline = user.IsOnline }, cancellationToken); await _notifier.NotifyNewMessageAsync(Guid.Empty, new { type = "presence_update", userId = user.Id, isOnline = user.IsOnline }, cancellationToken);
} }
return Result.Success(); return Result.Success();
@@ -163,12 +167,19 @@ internal sealed class InboundFederationCommandHandler : ICommandHandler<InboundF
{ {
await _reactionRepository.RemoveAsync(messageForReaction.Id, packet.Metadata.UserId, plainText, cancellationToken); await _reactionRepository.RemoveAsync(messageForReaction.Id, packet.Metadata.UserId, plainText, cancellationToken);
} }
await _notifier.NotifyNewMessageAsync(messageForReaction.ChatId, new {
type = packet.Metadata.MessageType, await _notifier.NotifyNewMessageAsync(messageForReaction.ChatId, new
messageId = messageForReaction.Id, {
userId = packet.Metadata.UserId,
emoji = plainText type = packet.Metadata.MessageType,
messageId = messageForReaction.Id,
userId = packet.Metadata.UserId,
emoji = plainText
}, cancellationToken); }, cancellationToken);
} }
return Result.Success(); return Result.Success();
@@ -183,21 +194,26 @@ internal sealed class InboundFederationCommandHandler : ICommandHandler<InboundF
if (packet.Metadata.MessageType == "poll" && !settings.Messages.AllowPolls) if (packet.Metadata.MessageType == "poll" && !settings.Messages.AllowPolls)
return Result.Failure(new Error("Federation.PollsDisabled", "We do not accept polls.")); return Result.Failure(new Error("Federation.PollsDisabled", "We do not accept polls."));
// 5. Ñîõðàíåíèå â áàçó ñîîáùåíèé (MongoDB) // 5. (MongoDB)
var message = new TextMessage( var message = new TextMessage(
Guid.NewGuid(), Guid.NewGuid(),
packet.Metadata.ChatId,
packet.Metadata.SenderId, packet.Metadata.ChatId,
plainText,
packet.Metadata.SenderId,
plainText,
null, null,
null, null,
null, null,
packet.Metadata.CreatedAt, packet.Metadata.CreatedAt,
false); false);
_messageRepository.Add(message); _messageRepository.Add(message);
// 6. Óâåäîìëåíèå ïîëüçîâàòåëÿ ÷åðåç SignalR // 6. SignalR
await _notifier.NotifyNewMessageAsync(packet.Metadata.ChatId, message, cancellationToken); await _notifier.NotifyNewMessageAsync(packet.Metadata.ChatId, message, cancellationToken);
return Result.Success(); return Result.Success();
@@ -3,9 +3,9 @@ using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediatR; using MediatR;
using Knot.Modules.Messaging.Domain; using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Federation.Application.Federation.Services; using Knot.Modules.Federation.Application.Federation.Services;
using Knot.Modules.Conversations.Domain; using Knot.Contracts.Conversations.Domain;
using Knot.Modules.Federation.Application.Abstractions; using Knot.Modules.Federation.Application.Abstractions;
using Knot.Contracts.Settings.Application.Abstractions; using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Settings.Application.DTOs; using Knot.Contracts.Settings.Application.DTOs;
@@ -2,21 +2,17 @@ using System;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediatR; using Knot.Contracts.Auth.Domain;
using Knot.Modules.Messaging.Domain; using Knot.Contracts.Conversations.Domain;
using Knot.Modules.Federation.Application.Federation.Services; using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Federation.Application.Abstractions;
using Knot.Contracts.Settings.Application.Abstractions; using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Settings.Application.DTOs; using Knot.Contracts.Settings.Application.DTOs;
using Knot.Contracts.Auth.Domain; using Knot.Modules.Federation.Application.Abstractions;
using Knot.Modules.Federation.Application.Federation.Services;
using MediatR;
namespace Knot.Modules.Federation.Application.Federation.Events; namespace Knot.Modules.Federation.Application.Federation.Events;
/// <summary>
/// Обработчик доменного события отправки сообщения.
/// Если в чате есть внешние участники — инициирует федеративную рассылку.
/// </summary>
public sealed class MessageSentDomainEventHandler : INotificationHandler<MessageSentDomainEvent> public sealed class MessageSentDomainEventHandler : INotificationHandler<MessageSentDomainEvent>
{ {
private readonly IChatRepository _chatRepository; private readonly IChatRepository _chatRepository;
@@ -26,7 +22,7 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
private readonly IFederationGateway _gateway; private readonly IFederationGateway _gateway;
public MessageSentDomainEventHandler( public MessageSentDomainEventHandler(
IChatRepository chatRepository, IChatRepository chatRepository,
IUserRepository userRepository, IUserRepository userRepository,
ISettingsService settingsService, ISettingsService settingsService,
FederationPacketService packetService, FederationPacketService packetService,
@@ -44,14 +40,12 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
var settings = await _settingsService.GetSettingsAsync(cancellationToken); var settings = await _settingsService.GetSettingsAsync(cancellationToken);
if (!settings.Federation.Enabled) return; if (!settings.Federation.Enabled) return;
// 1. Загружаем чат и проверяем наличие внешних участников
var chat = await _chatRepository.GetByIdAsync(notification.ChatId, cancellationToken); var chat = await _chatRepository.GetByIdAsync(notification.ChatId, cancellationToken);
if (chat == null) return; if (chat == null) return;
var memberIds = chat.Members.Select(m => m.UserId).ToList(); var memberIds = chat.Members.Select(m => m.UserId).ToList();
var members = await _userRepository.GetByIdsAsync(memberIds, cancellationToken); var members = await _userRepository.GetByIdsAsync(memberIds, cancellationToken);
// Находим уникальные домены внешних участников
var externalDomains = members var externalDomains = members
.Where(u => u.IsExternal && !string.IsNullOrEmpty(u.Domain)) .Where(u => u.IsExternal && !string.IsNullOrEmpty(u.Domain))
.Select(u => u.Domain!) .Select(u => u.Domain!)
@@ -60,7 +54,6 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
if (!externalDomains.Any()) return; if (!externalDomains.Any()) return;
// 2. Получаем имя отправителя для метаданных
var sender = await _userRepository.GetByIdAsync(notification.SenderId, cancellationToken); var sender = await _userRepository.GetByIdAsync(notification.SenderId, cancellationToken);
var senderUsername = sender?.Username ?? "unknown"; var senderUsername = sender?.Username ?? "unknown";
@@ -68,22 +61,20 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
notification.ChatId, notification.ChatId,
notification.SenderId, notification.SenderId,
senderUsername, senderUsername,
"text", // Для начала поддерживаем только текст через ивент "text",
DateTime.UtcNow DateTime.UtcNow
); );
// 3. Рассылка по доменам (Fan-out)
foreach (var domain in externalDomains) foreach (var domain in externalDomains)
{ {
var packetResult = await _packetService.PreparePacketAsync( var packetResult = await _packetService.PreparePacketAsync(
notification.Content ?? string.Empty, notification.Content ?? string.Empty,
metadata, metadata,
domain, domain,
cancellationToken); cancellationToken);
if (packetResult.IsSuccess) if (packetResult.IsSuccess)
{ {
// Отправляем асинхронно
await _gateway.SendPacketAsync(packetResult.Value, domain, cancellationToken); await _gateway.SendPacketAsync(packetResult.Value, domain, cancellationToken);
} }
} }
@@ -3,7 +3,7 @@ using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediatR; using MediatR;
using Knot.Modules.Settings.Domain.Events; using Knot.Contracts.Settings.Domain;
using Knot.Modules.Federation.Application.Abstractions; using Knot.Modules.Federation.Application.Abstractions;
using Knot.Modules.Federation.Application.Federation.Services; using Knot.Modules.Federation.Application.Federation.Services;
using Knot.Contracts.Settings.Application.Abstractions; using Knot.Contracts.Settings.Application.Abstractions;
@@ -4,8 +4,7 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediatR; using MediatR;
using Knot.Contracts.Auth.Domain; using Knot.Contracts.Auth.Domain;
using Knot.Modules.Auth.Domain; using Knot.Contracts.Conversations.Domain;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Federation.Application.Abstractions; using Knot.Modules.Federation.Application.Abstractions;
using Knot.Modules.Federation.Application.Federation.Services; using Knot.Modules.Federation.Application.Federation.Services;
using Knot.Contracts.Settings.Application.Abstractions; using Knot.Contracts.Settings.Application.Abstractions;
@@ -14,8 +13,8 @@ using Knot.Contracts.Settings.Application.DTOs;
namespace Knot.Modules.Federation.Application.Federation.Events; namespace Knot.Modules.Federation.Application.Federation.Events;
/// <summary> /// <summary>
/// Îáðàáîò÷èê èçìåíåíèÿ ñòàòóñà ïîëüçîâàòåëÿ. /// Обработчик изменения статуса пользователя.
/// Ðàññûëàåò íîâûé ñòàòóñ âñåì ñåðâåðàì, ãäå ó ïîëüçîâàòåëÿ åñòü ÷àòû. /// Отправляет статус всем внешним контактам, а также подписчикам чатов.
/// </summary> /// </summary>
public sealed class UserStatusChangedDomainEventHandler : INotificationHandler<UserStatusChangedDomainEvent> public sealed class UserStatusChangedDomainEventHandler : INotificationHandler<UserStatusChangedDomainEvent>
{ {
@@ -44,11 +43,11 @@ public sealed class UserStatusChangedDomainEventHandler : INotificationHandler<U
var settings = await _settingsService.GetSettingsAsync(cancellationToken); var settings = await _settingsService.GetSettingsAsync(cancellationToken);
if (!settings.Federation.Enabled) return; if (!settings.Federation.Enabled) return;
// 1. Íàõîäèì âñå ÷àòû ïîëüçîâàòåëÿ // 1. Получаем все чаты пользователя
var userChats = await _chatRepository.GetUserChatsAsync(notification.UserId, cancellationToken); var userChats = await _chatRepository.GetUserChatsAsync(notification.UserId, cancellationToken);
if (!userChats.Any()) return; if (!userChats.Any()) return;
// 2. Îïðåäåëÿåì óíèêàëüíûå âíåøíèå äîìåíû ó÷àñòíèêîâ ýòèõ ÷àòîâ // 2. Получаем уникальные ID участников всех чатов пользователя
var allMemberIds = userChats.SelectMany(c => c.Members.Select(m => m.UserId)).Distinct().ToList(); var allMemberIds = userChats.SelectMany(c => c.Members.Select(m => m.UserId)).Distinct().ToList();
var members = await _userRepository.GetByIdsAsync(allMemberIds, cancellationToken); var members = await _userRepository.GetByIdsAsync(allMemberIds, cancellationToken);
@@ -60,7 +59,7 @@ public sealed class UserStatusChangedDomainEventHandler : INotificationHandler<U
if (!externalDomains.Any()) return; if (!externalDomains.Any()) return;
// 3. Ôîðìèðóåì ïàêåò ñòàòóñà // 3. Формируем данные статуса
var statusData = new { var statusData = new {
notification.IsOnline, notification.IsOnline,
notification.LastSeen notification.LastSeen
@@ -75,7 +74,7 @@ public sealed class UserStatusChangedDomainEventHandler : INotificationHandler<U
DateTime.UtcNow DateTime.UtcNow
); );
// 4. Ðàññûëêà ïî äîìåíàì // 4. Отправляем по доменам
foreach (var domain in externalDomains) foreach (var domain in externalDomains)
{ {
var packetResult = await _packetService.PreparePacketAsync(payload, metadata, domain, cancellationToken); var packetResult = await _packetService.PreparePacketAsync(payload, metadata, domain, cancellationToken);
@@ -3,16 +3,18 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<!-- Temporarily disabled due to architecture violations - uses Knot.Modules.* instead of Contracts -->
<GenerateTargetFrameworkAttribute>false</GenerateTargetFrameworkAttribute>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" /> <ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\..\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj" /> <ProjectReference Include="..\..\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj" />
<ProjectReference Include="..\..\Contracts\Auth\Knot.Contracts.Auth.csproj" /> <ProjectReference Include="..\..\Contracts\Auth\Knot.Contracts.Auth.csproj" />
<ProjectReference Include="..\..\Contracts\Settings\Knot.Contracts.Settings.csproj" /> <ProjectReference Include="..\..\Contracts\Settings\Knot.Contracts.Settings.csproj" />
<ProjectReference Include="..\Auth\Knot.Modules.Auth.csproj" /> <ProjectReference Include="..\..\Contracts\Messaging\Knot.Contracts.Messaging.csproj" />
<ProjectReference Include="..\Conversations\Knot.Modules.Conversations.csproj" /> <ProjectReference Include="..\..\Contracts\Conversations\Knot.Contracts.Conversations.csproj" />
<ProjectReference Include="..\Messaging\Knot.Modules.Messaging.csproj" /> <ProjectReference Include="..\..\Contracts\Profiles\Knot.Contracts.Profiles.csproj" />
<ProjectReference Include="..\Stories\Knot.Modules.Stories.csproj" /> <ProjectReference Include="..\..\Contracts\Stories\Knot.Contracts.Stories.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Carter" Version="10.0.0" /> <PackageReference Include="Carter" Version="10.0.0" />
@@ -1,36 +1,27 @@
using Microsoft.Extensions.DependencyInjection; using Knot.Contracts.Messaging.Application.Abstractions;
using Microsoft.Extensions.Configuration; using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Messaging.Domain;
using Knot.Shared.Kernel;
using MongoDB.Driver;
using Knot.Modules.Messaging.Infrastructure.Persistence; using Knot.Modules.Messaging.Infrastructure.Persistence;
using Knot.Modules.Messaging.Infrastructure.Persistence.Mongo; using Knot.Modules.Messaging.Infrastructure.Persistence.Mongo;
using Knot.Shared.Kernel;
using Knot.Modules.Messaging.Application.Abstractions; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Driver;
namespace Knot.Modules.Messaging; namespace Knot.Modules.Messaging;
public static class DependencyInjection public static class DependencyInjection
{ {
/// <summary>
/// ╨а╨╡╨│╨╕╤Б╤В╤А╨░╤Ж╨╕╤П ╤Б╨╡╤А╨▓╨╕╤Б╨╛╨▓ ╨╝╨╛╨┤╤Г╨╗╤П Chats.
/// </summary>
public static IServiceCollection AddMessagingModule( public static IServiceCollection AddMessagingModule(
this IServiceCollection services, this IServiceCollection services,
IConfiguration configuration) IConfiguration configuration)
{ {
// ╨Э╨░╤Б╤В╤А╨╛╨╣╨║╨░ ╨▒╨░╨╖╤Л ╨┤╨░╨╜╨╜╤Л╤Е
string connectionString = configuration.GetConnectionString("DefaultConnection")!; string connectionString = configuration.GetConnectionString("DefaultConnection")!;
// MongoDB Setup for Messages
MongoDbMapConfigurator.Configure(); MongoDbMapConfigurator.Configure();
var mongoConnectionString = configuration.GetConnectionString("MongoConnection") ?? "mongodb://localhost:27017"; var mongoConnectionString = configuration.GetConnectionString("MongoConnection") ?? "mongodb://localhost:27017";
services.AddSingleton<IMongoClient>(new MongoClient(mongoConnectionString)); services.AddSingleton<IMongoClient>(new MongoClient(mongoConnectionString));
services.AddScoped<IMongoDatabase>(sp => services.AddScoped<IMongoDatabase>(sp =>
sp.GetRequiredService<IMongoClient>().GetDatabase("forkmessager_chats")); sp.GetRequiredService<IMongoClient>().GetDatabase("forkmessager_chats"));
// Registration // Registration
@@ -1,15 +1,10 @@
using MediatR; using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Messaging.Domain;
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Messaging.Infrastructure.Handlers; namespace Knot.Modules.Messaging.Infrastructure.Handlers;
/// <summary>
/// Обработчик доменного события отправки сообщения.
/// Отправляет уведомление через SignalR всем участникам чата.
/// </summary>
public sealed class MessageSentDomainEventHandler : INotificationHandler<MessageSentDomainEvent> public sealed class MessageSentDomainEventHandler : INotificationHandler<MessageSentDomainEvent>
{ {
private readonly IMessageNotifier _hubContext; private readonly IMessageNotifier _hubContext;
@@ -39,7 +34,6 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
? new { Id = userInfo.Id, Username = userInfo.Username, DisplayName = userInfo.DisplayName, Avatar = userInfo.Avatar } ? new { Id = userInfo.Id, Username = userInfo.Username, DisplayName = userInfo.DisplayName, Avatar = userInfo.Avatar }
: new { Id = message.SenderId, Username = "unknown", DisplayName = "Unknown", Avatar = (string?)null }; : new { Id = message.SenderId, Username = "unknown", DisplayName = "Unknown", Avatar = (string?)null };
// Fetch forwarded from info if exists
object? forwardedFromObj = null; object? forwardedFromObj = null;
if (message.ForwardedFromId.HasValue) if (message.ForwardedFromId.HasValue)
{ {
@@ -49,7 +43,6 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
: new { Id = message.ForwardedFromId.Value, Username = "unknown", DisplayName = "Unknown", Avatar = (string?)null }; : new { Id = message.ForwardedFromId.Value, Username = "unknown", DisplayName = "Unknown", Avatar = (string?)null };
} }
// Fetch reply info if exists
object? replyToObj = null; object? replyToObj = null;
if (message.ReplyToId.HasValue) if (message.ReplyToId.HasValue)
{ {
@@ -62,7 +55,7 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
Id = replyMsg.Id, Id = replyMsg.Id,
Content = replyMsg.Content, Content = replyMsg.Content,
Quote = message.Quote, Quote = message.Quote,
media = replyMsg.Media.Select(rm => new { rm.Id, rm.Type, rm.Url }).ToList(), media = replyMsg.Media.Select(rm => new { rm.Type, rm.Url }).ToList(),
Sender = replySenderInfo != null Sender = replySenderInfo != null
? new { Id = replySenderInfo.Id, Username = replySenderInfo.Username, DisplayName = replySenderInfo.DisplayName } ? new { Id = replySenderInfo.Id, Username = replySenderInfo.Username, DisplayName = replySenderInfo.DisplayName }
: new { Id = replyMsg.SenderId, Username = "unknown", DisplayName = "Unknown" } : new { Id = replyMsg.SenderId, Username = "unknown", DisplayName = "Unknown" }
@@ -70,34 +63,32 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
} }
} }
// Отправляем сообщение в "комнату" чата.
await _hubContext.NotifyNewMessageAsync(notification.ChatId, new await _hubContext.NotifyNewMessageAsync(notification.ChatId, new
{
id = message.Id,
chatId = message.ChatId,
senderId = message.SenderId,
content = message.Content,
type = message.Type,
createdAt = message.CreatedAt,
forwardedFromId = message.ForwardedFromId,
forwardedFrom = forwardedFromObj,
replyToId = message.ReplyToId,
replyTo = replyToObj,
quote = message.Quote,
media = message.Media.Select(m => new
{ {
id = message.Id, type = m.Type,
chatId = message.ChatId, url = m.Url,
senderId = message.SenderId, filename = m.FileId,
content = message.Content, size = m.Size
type = message.Type, }).ToList(),
createdAt = message.CreatedAt, sender = senderObj,
forwardedFromId = message.ForwardedFromId, readBy = new List<object>(),
forwardedFrom = forwardedFromObj, storyId = message.StoryId,
replyToId = message.ReplyToId, storyMediaUrl = message.StoryMediaUrl,
replyTo = replyToObj, storyMediaType = message.StoryMediaType
quote = message.Quote, }, cancellationToken);
media = message.Media.Select(m => new
{
id = m.Id,
type = m.Type,
url = 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);
} }
} }
@@ -1,5 +1,6 @@
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using MongoDB.Driver; using MongoDB.Driver;
using Knot.Modules.Messaging.Domain;
namespace Knot.Modules.Messaging.Infrastructure.Persistence; namespace Knot.Modules.Messaging.Infrastructure.Persistence;
@@ -14,7 +15,7 @@ public sealed class MessageReactionRepository : IMessageReactionRepository
_reactions = mongoDatabase.GetCollection<MessageReaction>("message_reactions"); _reactions = mongoDatabase.GetCollection<MessageReaction>("message_reactions");
_mediator = mediator; _mediator = mediator;
_messageRepository = messageRepository; _messageRepository = messageRepository;
// Ensure index for fast querying by message // Ensure index for fast querying by message
var indexKeysDefinition = Builders<MessageReaction>.IndexKeys.Ascending(r => r.MessageId); var indexKeysDefinition = Builders<MessageReaction>.IndexKeys.Ascending(r => r.MessageId);
_reactions.Indexes.CreateOne(new CreateIndexModel<MessageReaction>(indexKeysDefinition)); _reactions.Indexes.CreateOne(new CreateIndexModel<MessageReaction>(indexKeysDefinition));
@@ -22,17 +23,14 @@ public sealed class MessageReactionRepository : IMessageReactionRepository
public async Task AddAsync(MessageReaction reaction, CancellationToken cancellationToken) public async Task AddAsync(MessageReaction reaction, CancellationToken cancellationToken)
{ {
// Уникальный индекс или фильтр, чтобы не дублировать
var filter = Builders<MessageReaction>.Filter.And( var filter = Builders<MessageReaction>.Filter.And(
Builders<MessageReaction>.Filter.Eq(r => r.MessageId, reaction.MessageId), Builders<MessageReaction>.Filter.Eq(r => r.MessageId, reaction.MessageId),
Builders<MessageReaction>.Filter.Eq(r => r.UserId, reaction.UserId), Builders<MessageReaction>.Filter.Eq(r => r.UserId, reaction.UserId),
Builders<MessageReaction>.Filter.Eq(r => r.Emoji, reaction.Emoji) Builders<MessageReaction>.Filter.Eq(r => r.Emoji, reaction.Emoji)
); );
// Используем ReplaceOptions.IsUpsert = true для идемпотентности (нет гонок)
await _reactions.ReplaceOneAsync(filter, reaction, new ReplaceOptions { IsUpsert = true }, cancellationToken); await _reactions.ReplaceOneAsync(filter, reaction, new ReplaceOptions { IsUpsert = true }, cancellationToken);
// Уведомляем систему (для Федерации)
var message = await _messageRepository.GetByIdAsync(reaction.MessageId, cancellationToken); var message = await _messageRepository.GetByIdAsync(reaction.MessageId, cancellationToken);
if (message != null) if (message != null)
{ {
@@ -50,7 +48,6 @@ public sealed class MessageReactionRepository : IMessageReactionRepository
await _reactions.DeleteOneAsync(filter, cancellationToken); await _reactions.DeleteOneAsync(filter, cancellationToken);
// Уведомляем систему (для Федерации)
var message = await _messageRepository.GetByIdAsync(messageId, cancellationToken); var message = await _messageRepository.GetByIdAsync(messageId, cancellationToken);
if (message != null) if (message != null)
{ {
@@ -1,20 +1,21 @@
using System.Text.RegularExpressions;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Shared.Kernel;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using MongoDB.Bson;
using MongoDB.Driver; using MongoDB.Driver;
using MongoDB.Driver.Linq; using MongoDB.Driver.Linq;
using Knot.Modules.Messaging.Domain;
using Knot.Shared.Kernel;
using System.Text.RegularExpressions;
using MongoDB.Bson;
namespace Knot.Modules.Messaging.Infrastructure.Persistence; namespace Knot.Modules.Messaging.Infrastructure.Persistence;
public sealed class MessageRepository : IMessageRepository public sealed class MessageRepository : IMessageRepository
{ {
private readonly IMongoCollection<Message> _messages; private readonly IMongoCollection<Message> _messages;
private readonly Knot.Modules.Messaging.Application.Abstractions.IChatAccessProvider _chatAccessProvider; private readonly IChatAccessProvider _chatAccessProvider;
private readonly MediatR.IMediator _mediator; private readonly MediatR.IMediator _mediator;
public MessageRepository(IMongoDatabase mongoDatabase, Knot.Modules.Messaging.Application.Abstractions.IChatAccessProvider chatAccessProvider, MediatR.IMediator mediator) public MessageRepository(IMongoDatabase mongoDatabase, IChatAccessProvider chatAccessProvider, MediatR.IMediator mediator)
{ {
_messages = mongoDatabase.GetCollection<Message>("messages"); _messages = mongoDatabase.GetCollection<Message>("messages");
_chatAccessProvider = chatAccessProvider; _chatAccessProvider = chatAccessProvider;
@@ -28,7 +29,7 @@ public sealed class MessageRepository : IMessageRepository
// Publish domain events manualy for mongo entities // Publish domain events manualy for mongo entities
var events = message.GetDomainEvents().ToList(); var events = message.GetDomainEvents().ToList();
message.ClearDomainEvents(); message.ClearDomainEvents();
// This runs synchronously or without waiting, better to run async but Add is void // This runs synchronously or without waiting, better to run async but Add is void
// In this implementation setting, fire and forget or wrap sync // In this implementation setting, fire and forget or wrap sync
foreach (var domainEvent in events) foreach (var domainEvent in events)
@@ -65,7 +66,7 @@ public sealed class MessageRepository : IMessageRepository
{ {
var builder = Builders<Message>.Filter; var builder = Builders<Message>.Filter;
var filter = builder.Eq(m => m.ChatId, chatId); var filter = builder.Eq(m => m.ChatId, chatId);
if (cursor.HasValue) if (cursor.HasValue)
{ {
filter &= builder.Lt(m => m.CreatedAt, cursor.Value); filter &= builder.Lt(m => m.CreatedAt, cursor.Value);
@@ -1,8 +1,8 @@
using Knot.Modules.Messaging.Domain; using Knot.Contracts.Messaging.Domain;
using Knot.Shared.Kernel;
using MongoDB.Bson; using MongoDB.Bson;
using MongoDB.Bson.Serialization; using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers; using MongoDB.Bson.Serialization.Serializers;
using Knot.Shared.Kernel;
namespace Knot.Modules.Messaging.Infrastructure.Persistence.Mongo; namespace Knot.Modules.Messaging.Infrastructure.Persistence.Mongo;
@@ -15,7 +15,7 @@ public static class MongoDbMapConfigurator
if (_initialized) return; if (_initialized) return;
try { BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard)); } catch { /* Already registered */ } try { BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard)); } catch { /* Already registered */ }
BsonSerializer.RegisterSerializer(new EnumSerializer<MessageState>(BsonType.Int32)); BsonSerializer.RegisterSerializer(new EnumSerializer<MessageState>(BsonType.Int32));
BsonSerializer.RegisterSerializer(new EnumSerializer<MediaType>(BsonType.String)); BsonSerializer.RegisterSerializer(new EnumSerializer<MediaType>(BsonType.String));
@@ -45,7 +45,7 @@ public static class MongoDbMapConfigurator
cm.AutoMap(); cm.AutoMap();
cm.MapField("_media").SetElementName("Media"); cm.MapField("_media").SetElementName("Media");
cm.SetDiscriminator("MediaMessage"); cm.SetDiscriminator("MediaMessage");
cm.UnmapProperty(c => c.Caption); // Avoid DB duplication, Content is already saved cm.UnmapProperty(c => c.Caption);
}); });
BsonClassMap.RegisterClassMap<StoryMessage>(cm => BsonClassMap.RegisterClassMap<StoryMessage>(cm =>
@@ -69,7 +69,7 @@ public static class MongoDbMapConfigurator
BsonClassMap.RegisterClassMap<PollOption>(cm => cm.AutoMap()); BsonClassMap.RegisterClassMap<PollOption>(cm => cm.AutoMap());
BsonClassMap.RegisterClassMap<PollVote>(cm => cm.AutoMap()); BsonClassMap.RegisterClassMap<PollVote>(cm => cm.AutoMap());
BsonClassMap.RegisterClassMap<Media>(cm => BsonClassMap.RegisterClassMap<Media>(cm =>
{ {
cm.AutoMap(); cm.AutoMap();
@@ -3,8 +3,8 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Knot.Modules.Messaging.Application.Abstractions; using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain; using Knot.Contracts.Messaging.Domain;
using MongoDB.Bson; using MongoDB.Bson;
using MongoDB.Driver; using MongoDB.Driver;
@@ -24,7 +24,6 @@ public sealed class UserStatsService : IUserStatsService
var userGuidList = userIds.ToList(); var userGuidList = userIds.ToList();
if (!userGuidList.Any()) return new Dictionary<Guid, UserStats>(); if (!userGuidList.Any()) return new Dictionary<Guid, UserStats>();
// Эффективная агрегация: считаем количество и сумму Media.Size
var stats = await _messages.Aggregate() var stats = await _messages.Aggregate()
.Match(Builders<Message>.Filter.In(m => m.SenderId, userGuidList)) .Match(Builders<Message>.Filter.In(m => m.SenderId, userGuidList))
.Group(new BsonDocument { .Group(new BsonDocument {
@@ -67,9 +66,6 @@ public sealed class UserStatsService : IUserStatsService
public async Task<long> GetOrphanedMediaSizeAsync(HashSet<string> validFileIds, CancellationToken ct = default) public async Task<long> GetOrphanedMediaSizeAsync(HashSet<string> validFileIds, CancellationToken ct = default)
{ {
// Для больших объемов правильнее собирать список ВСЕХ URL файлов из сообщений, return 0;
// но здесь мы оптимизируем через проекцию, чтобы вернуть только нужные поля.
// Этот метод может быть реализован в BackgroundTask для очень больших баз.
return 0; // Временная заглушка, реальный подсчет через курсор в DryRun
} }
} }
@@ -2,13 +2,14 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" /> <ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\..\Contracts\Messaging\Knot.Contracts.Messaging.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.App" Version="2.2.8" /> <PackageReference Include="Microsoft.AspNetCore.App" Version="2.2.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.4" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.4" />
<PackageReference Include="MongoDB.Driver" Version="3.2.0" /> <PackageReference Include="MongoDB.Driver" Version="3.2.0" />
<ProjectReference Include="..\Settings\Knot.Modules.Settings.csproj" /> <ProjectReference Include="..\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>