Структура

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

View File

@@ -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);
}
}

View File

@@ -8,7 +8,8 @@ using NSubstitute;
using Xunit;
using Knot.Shared.Kernel;
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.Chats.GetChats;
using Knot.Modules.Conversations.Application.DTOs;

View File

@@ -1,10 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<!-- Temporarily disabled - needs refactoring to use Contracts instead of Modules -->
<GenerateTargetFrameworkAttribute>false</GenerateTargetFrameworkAttribute>
</PropertyGroup>
<ItemGroup>
@@ -23,10 +24,9 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Modules\Chats\Knot.Modules.Conversations.csproj" />
<ProjectReference Include="..\..\..\src\Modules\Messaging\Knot.Modules.Messaging.csproj" />
<ProjectReference Include="..\..\..\src\Contracts\Messaging\Knot.Contracts.Messaging.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>
</Project>

View File

@@ -9,7 +9,7 @@ using NSubstitute;
using Xunit;
using Knot.Shared.Kernel;
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.Messages.Send;
using Knot.Contracts.Settings.Application.Abstractions;

View File

@@ -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;

View File

@@ -0,0 +1,7 @@
using Knot.Shared.Kernel;
namespace Knot.Contracts.Conversations.Application.Abstractions;
public interface IChatsUnitOfWork : IUnitOfWork
{
}

View File

@@ -0,0 +1,8 @@
using System;
namespace Knot.Contracts.Conversations.Application.Abstractions;
public interface IUserStatusService
{
bool IsUserOnline(string userId);
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -8,6 +8,7 @@
<ItemGroup>
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\Messaging\Knot.Contracts.Messaging.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -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);
}

View File

@@ -0,0 +1,6 @@
namespace Knot.Contracts.Messaging.Application.Abstractions;
public interface IMessageNotifier
{
Task NotifyNewMessageAsync(Guid chatId, object messagePayload, CancellationToken cancellationToken);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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) { }
}

View File

@@ -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);
}

View File

@@ -0,0 +1,9 @@
namespace Knot.Contracts.Messaging.Domain;
public enum MediaType
{
Image,
Video,
Voice,
File
}

View 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; } }

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,8 @@
using Knot.Shared.Kernel;
namespace Knot.Contracts.Settings.Domain;
/// <summary>
/// Доменное событие: системные настройки обновлены.
/// </summary>
public sealed record SystemSettingsUpdatedDomainEvent(Application.DTOs.SystemSettingsDto Settings) : IDomainEvent;

View File

@@ -1,18 +1,19 @@
using Knot.Modules.Admin.Application.Admin.DTOs;
using Knot.Modules.Messaging.Domain;
using System;
using System.Linq;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using MongoDB.Driver;
using Microsoft.EntityFrameworkCore;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Admin.Application.Admin.DTOs;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.Persistence;
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;
@@ -37,7 +38,7 @@ internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand,
public async Task<Result<MessageResponse>> Handle(CleanRunCommand request, CancellationToken cancellationToken)
{
try
try
{
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken);
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 allUsers = await _identityDb.Users.AsNoTracking().ToListAsync(cancellationToken);
var allStories = await _stories.Find(_ => true).ToListAsync(cancellationToken);
var validUrls = new HashSet<string>();
@@ -73,7 +74,7 @@ internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand,
var activeUserUrls = allUsers
.Where(u => !string.IsNullOrEmpty(u.Avatar))
.Select(u => u.Avatar!);
var activeStoryUrls = allStories
.Where(s => !string.IsNullOrEmpty(s.MediaUrl))
.Select(s => s.MediaUrl!);

View File

@@ -1,21 +1,22 @@
using Knot.Modules.Admin.Application.Admin.DTOs;
using Knot.Modules.Messaging.Domain;
using System;
using System.Linq;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using MongoDB.Driver;
using MongoDB.Bson;
using Microsoft.EntityFrameworkCore;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Admin.Application.Admin.DTOs;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Knot.Modules.Auth.Application.Users;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Stories.Domain;
using Knot.Modules.Auth.Application.Auth.DTOs;
using Knot.Modules.Auth.Application.Users;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using MediatR;
using Microsoft.EntityFrameworkCore;
using MongoDB.Bson;
using MongoDB.Driver;
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)
{
try
try
{
// 1. Получаем ID активных чатов (SQL)
var activeChats = await _chatsDbContext.Chats
.AsNoTracking()
.Select(c => new { c.Id, c.Avatar })
.ToListAsync(ct);
var activeChatIds = activeChats.Select(c => c.Id).ToHashSet();
// 2. Считаем сообщения подлежащие удалению (MongoDB)
@@ -62,7 +63,7 @@ internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery,
// Аватары чатов и пользователей
foreach (var chat in activeChats) AddFileIdIfValid(chat.Avatar, validFileIds);
var userAvatars = await _identityDb.Users.AsNoTracking()
.Where(u => !string.IsNullOrEmpty(u.Avatar))
.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.In(m => m.ChatId, activeChatIds)
);
var projection = Builders<Message>.Projection.Include("Media");
using (var cursor = await _messages.Find(activeFilter).Project(projection).ToCursorAsync(ct))
{
while (await cursor.MoveNextAsync(ct))

View File

@@ -10,11 +10,16 @@
<ProjectReference Include="..\..\Contracts\Auth\Knot.Contracts.Auth.csproj" />
<ProjectReference Include="..\..\Contracts\Profiles\Knot.Contracts.Profiles.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\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="..\Conversations\Knot.Modules.Conversations.csproj" />
<ProjectReference Include="..\Stories\Knot.Modules.Stories.csproj" />
<ProjectReference Include="..\Klipy\Knot.Modules.Klipy.csproj" />
<ProjectReference Include="..\Messaging\Knot.Modules.Messaging.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Carter" Version="10.0.0" />
@@ -24,4 +29,4 @@
<_Parameter1>Knot.Modules.Admin.UnitTests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>
</Project>

View File

@@ -1,17 +1,17 @@
using Knot.Contracts.Auth.Domain;
using BCrypt.Net;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Contracts.Auth.Domain;
using Knot.Shared.Kernel;
using BCrypt.Net;
namespace Knot.Modules.Auth.Application.Users.Login;
/// <summary>
/// Êîìàíäà äëÿ âõîäà ïîëüçîâàòåëÿ. Âîçâðàùàåò AuthResponseDto.
/// <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> AuthResponseDto.
/// </summary>
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 IJwtTokenProvider _tokenProvider;

View File

@@ -1,15 +1,15 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Shared.Kernel;
using MediatR;
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 latestReactions = latestMessage != null
? await _reactionRepository.GetReactionsForMessageAsync(latestMessage.Id, cancellationToken)
var latestReactions = latestMessage != null
? await _reactionRepository.GetReactionsForMessageAsync(latestMessage.Id, cancellationToken)
: new List<MessageReaction>();
if (latestMessage != null)

View File

@@ -1,15 +1,15 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Conversations.Application.Chats.GetChats;
@@ -39,12 +39,12 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
foreach (var chat in userChats)
{
var latestMessage = await _messageRepository.GetLatestChatMessageAsync(chat.Id, cancellationToken);
var latestReactions = latestMessage != null
var latestReactions = latestMessage != null
? await _reactionRepository.GetReactionsForMessageAsync(latestMessage.Id, cancellationToken)
: new List<MessageReaction>();
var userIdsToFetch = new HashSet<Guid>();
foreach (var member in chat.Members)
{
userIdsToFetch.Add(member.UserId);

View File

@@ -1,10 +1,10 @@
using MediatR;
using Microsoft.AspNetCore.SignalR;
using global::Knot.Modules.Conversations.Application.Abstractions;
using global::Knot.Modules.Conversations.Domain;
using global::Knot.Modules.Conversations.Infrastructure.SignalR;
using global::Knot.Shared.Kernel;
using global::Knot.Modules.Conversations.Application.Abstractions;
using MessagingMessageRepository = Knot.Modules.Messaging.Domain.IMessageRepository;
using MediatR;
using Microsoft.AspNetCore.SignalR;
using MessagingMessageRepository = Knot.Contracts.Messaging.Application.Abstractions.IMessageRepository;
namespace Knot.Modules.Conversations.Application.Messages.Delete;

View File

@@ -1,16 +1,15 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Shared.Kernel;
using MediatR;
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 messageIds = filteredMessages.Select(m => m.Id).ToList();
var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken);
var reactionsByMessage = allReactions.GroupBy(r => r.MessageId).ToDictionary(g => g.Key, g => g.ToList());

View File

@@ -1,17 +1,16 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Conversations.Application.Messages.GetSharedMedia;

View File

@@ -1,12 +1,12 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using MediatR;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Infrastructure.SignalR;
using Knot.Shared.Kernel;
using MediatR;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace Knot.Modules.Conversations.Application.Messages.React;

View File

@@ -1,12 +1,12 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using MediatR;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Infrastructure.SignalR;
using Knot.Shared.Kernel;
using MediatR;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace Knot.Modules.Conversations.Application.Messages.React;

View File

@@ -1,14 +1,14 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using MediatR;
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));
var senders = await _userProvider.GetUsersInfoAsync(userIds.Distinct(), cancellationToken);
var messageIds = messages.Select(m => m.Id).ToList();
var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken);
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.SequenceId,
message.ForwardedFromId,
null,
null,
message.StoryId,
message.StoryMediaUrl,
message.StoryMediaType,

View File

@@ -1,14 +1,14 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
namespace Knot.Modules.Conversations.Application.Messages.Send;
/// <summary>
/// Êîìàíäà äëÿ îòïðàâêè ñîîáùåíèÿ â ÷àò.
/// <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20> <20><><EFBFBD>.
/// </summary>
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)
{
// 1. Ïðîâåðÿåì ñóùåñòâîâàíèå ÷àòà
// 1. <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD>
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat is null)
{
return Result.Failure<Guid>(ChatErrors.ChatsNotFound);
}
// 2. Ïðîâåðÿåì, ÿâëÿåòñÿ ëè îòïðàâèòåëü ó÷àñòíèêîì
// 2. <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>, <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
if (!chat.Members.Any(m => m.UserId == request.SenderId))
{
return Result.Failure<Guid>(ChatErrors.ChatsForbidden);
}
// 3. Ñîçäàåì ñîîáùåíèå
// 3. <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
Message message;
if (request.Type == "story_reply" || request.Type == "story_reaction")
{
if (!_messagesSettings.Current.AllowMedia) return Result.Failure<Guid>(ChatErrors.MediaDisabled);
var parsedStoryMediaType = Enum.TryParse<MediaType>(request.StoryMediaType, true, out var sTypeEnum) ? sTypeEnum : MediaType.Image;
message = new StoryMessage(
Guid.NewGuid(),
request.ChatId,
request.SenderId,
request.StoryId ?? Guid.Empty,
request.StoryMediaUrl ?? string.Empty,
parsedStoryMediaType,
request.Content,
request.ReplyToId,
request.ForwardedFromId,
DateTime.UtcNow,
Guid.NewGuid(),
request.ChatId,
request.SenderId,
request.StoryId ?? Guid.Empty,
request.StoryMediaUrl ?? string.Empty,
request.StoryMediaType,
request.Content,
request.ReplyToId,
request.ForwardedFromId,
DateTime.UtcNow,
false);
}
else if (request.Attachments != null && request.Attachments.Any())
@@ -92,22 +102,31 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
var firstAtt = request.Attachments.First();
var parsedType = Enum.TryParse<MediaType>(firstAtt.Type, true, out var mTypeEnum) ? mTypeEnum : MediaType.File;
message = new MediaMessage(
Guid.NewGuid(),
request.ChatId,
request.SenderId,
parsedType,
request.Content,
request.ReplyToId,
request.ForwardedFromId,
DateTime.UtcNow,
Guid.NewGuid(),
request.ChatId,
request.SenderId,
parsedType,
request.Content,
request.ReplyToId,
request.ForwardedFromId,
DateTime.UtcNow,
false);
foreach (var att in request.Attachments)
{
var pType = Enum.TryParse<MediaType>(att.Type, true, out var tEnum) ? tEnum : MediaType.File;
((MediaMessage)message).AddMedia(pType, att.Url, att.FileName, att.FileSize);
((MediaMessage)message).AddMedia(pType.ToString().ToLower(), att.Url, att.FileName, att.FileSize);
}
}
else if (request.Type == "poll")
@@ -131,18 +150,26 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
else
{
message = new TextMessage(
Guid.NewGuid(),
request.ChatId,
request.SenderId,
request.Content ?? string.Empty,
request.ReplyToId,
request.Quote,
request.ForwardedFromId,
DateTime.UtcNow,
Guid.NewGuid(),
request.ChatId,
request.SenderId,
request.Content ?? string.Empty,
request.ReplyToId,
request.Quote,
request.ForwardedFromId,
DateTime.UtcNow,
false);
}
// 4. Ïîñëåäîâàòåëüíîñòü ñîîáùåíèé High-Water Mark
// 4. <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> High-Water Mark
chat.IncrementSequenceId();
message.SetSequenceId(chat.LastMessageSequenceId);
@@ -150,15 +177,19 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
senderMember.UpdateReadCursor(message.Id, message.SequenceId);
senderMember.UpdateDeliveredCursor(message.Id);
// 5. Ñîõðàíÿåì
// 5. <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
_messageRepository.Add(message);
await _unitOfWork.SaveChangesAsync(cancellationToken);
await _mediator.Publish(new MessageSentDomainEvent(
message.Id,
message.ChatId,
message.SenderId,
message.Content),
message.Id,
message.ChatId,
message.SenderId,
message.Content),
cancellationToken);
return Result.Success(message.Id);

View File

@@ -1,12 +1,12 @@
using Knot.Contracts.Auth.Domain;
using System.Text.RegularExpressions;
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.Messaging.Domain;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using MediatR;
using System.Text.RegularExpressions;
using Knot.Modules.Conversations.Application.Abstractions;
namespace Knot.Modules.Conversations.Application.Users.Commands.DeleteUser;
@@ -17,7 +17,7 @@ internal sealed class DeleteUserCommandHandler : ICommandHandler<DeleteUserComma
private readonly IUserRepository _userRepository;
private readonly IUserChatSettingsRepository _userChatSettingsRepository;
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 IChatsUnitOfWork _unitOfWork;
private readonly IAuthUnitOfWork _authUnitOfWork;
@@ -26,7 +26,7 @@ internal sealed class DeleteUserCommandHandler : ICommandHandler<DeleteUserComma
IUserRepository userRepository,
IUserChatSettingsRepository userChatSettingsRepository,
IUserFolderSettingsRepository userFolderSettingsRepository,
Knot.Modules.Messaging.Domain.IMessageRepository messageRepository,
Knot.Contracts.Messaging.Application.Abstractions.IMessageRepository messageRepository,
IFileStorageService fileStorage,
IChatsUnitOfWork unitOfWork,
IAuthUnitOfWork authUnitOfWork)
@@ -55,9 +55,9 @@ internal sealed class DeleteUserCommandHandler : ICommandHandler<DeleteUserComma
{
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));
if (!isUsedElsewhere)
{
var fileId = ExtractFileId(media.Url);

View File

@@ -1,27 +1,22 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using Knot.Modules.Messaging.Infrastructure.Persistence;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Knot.Modules.Conversations;
public static class DependencyInjection
{
/// <summary>
/// ╨а╨╡╨│╨╕╤Б╤В╤А╨░╤Ж╨╕╤П ╤Б╨╡╤А╨▓╨╕╤Б╨╛╨▓ ╨╝╨╛╨┤╤Г╨╗╤П Chats.
/// </summary>
public static IServiceCollection AddConversationsModule(
this IServiceCollection services,
IConfiguration configuration)
{
// ╨Э╨░╤Б╤В╤А╨╛╨╣╨║╨░ ╨▒╨░╨╖╤Л ╨┤╨░╨╜╨╜╤Л╤Е
string connectionString = configuration.GetConnectionString("DefaultConnection")!;
services.AddDbContext<ChatsDbContext>(options =>
@@ -29,7 +24,7 @@ public static class DependencyInjection
// MongoDB Setup for Messages
ConversationsMongoDbMapConfigurator.Configure();
var mongoConnectionString = configuration.GetConnectionString("MongoConnection") ?? "mongodb://localhost:27017";
// Registration
@@ -38,17 +33,17 @@ public static class DependencyInjection
services.AddScoped<IFolderRepository, FolderRepository>();
services.AddScoped<IUserChatSettingsRepository, UserChatSettingsRepository>();
services.AddScoped<IUserFolderSettingsRepository, UserFolderSettingsRepository>();
// Messaging Repository registration (might be redundant if already in Messaging module, but needed for specific commands in Conversations)
services.AddScoped<IMessageRepository, MessageRepository>();
// Messaging Repository - registered in Messaging module
// services.AddScoped<IMessageRepository, MessageRepository>();
// MediatR
services.AddMediatR(config =>
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
services.AddScoped<Knot.Modules.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.IChatAccessProvider, Knot.Modules.Conversations.Infrastructure.Services.ChatAccessProvider>();
services.AddScoped<Knot.Contracts.Messaging.Application.Abstractions.IMessageNotifier, Knot.Modules.Conversations.Infrastructure.SignalR.MessageNotifier>();
services.AddScoped<IUserStatusService, Knot.Modules.Conversations.Infrastructure.Services.UserStatusService>();
return services;
}
}
}

View File

@@ -1,11 +1,12 @@
namespace Knot.Modules.Conversations.Infrastructure.Services;
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
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); } }

View File

@@ -1,7 +1,8 @@
namespace Knot.Modules.Conversations.Infrastructure.SignalR;
using Knot.Modules.Messaging.Application.Abstractions;
using Microsoft.AspNetCore.SignalR;
using System;
using System.Threading;
using System.Threading.Tasks;
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); } }

View File

@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
@@ -10,8 +9,9 @@
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\..\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj" />
<ProjectReference Include="..\..\Contracts\Settings\Knot.Contracts.Settings.csproj" />
<ProjectReference Include="..\Messaging\Knot.Modules.Messaging.csproj" />
<ProjectReference Include="..\Auth\Knot.Modules.Auth.csproj" />
<ProjectReference Include="..\..\Contracts\Messaging\Knot.Contracts.Messaging.csproj" />
<ProjectReference Include="..\..\Contracts\Conversations\Knot.Contracts.Conversations.csproj" />
<ProjectReference Include="..\..\Contracts\Auth\Knot.Contracts.Auth.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -5,15 +5,15 @@ using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Modules.Federation.Application.Abstractions;
using Knot.Shared.Kernel;
using Knot.Contracts.Auth.Domain;
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Settings.Application.DTOs;
using Knot.Modules.Federation.Application.Abstractions;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Constants;
using MediatR;
namespace Host.Application.Federation.Commands;
@@ -29,7 +29,8 @@ internal sealed class InboundFederationCommandHandler : ICommandHandler<InboundF
private readonly IMediator _mediator;
public InboundFederationCommandHandler(
ISettingsService settingsService,
ISettingsService settingsService,
IMessageRepository messageRepository,
IUserRepository userRepository,
IMessageReactionRepository reactionRepository,
@@ -52,7 +53,7 @@ internal sealed class InboundFederationCommandHandler : ICommandHandler<InboundF
var packet = request.Packet;
// 1. Ïðîâåðÿåì ïîäïèñü îòïðàâèòåëÿ
// 1. <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
var senderConfig = settings.Federation.AllowedDomains.FirstOrDefault(d => d.IsEnabled && d.Domain.Equals(packet.SenderDomain, StringComparison.OrdinalIgnoreCase));
if (senderConfig == null || string.IsNullOrEmpty(senderConfig.PublicKey))
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. <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> IV <20><><EFBFBD><EFBFBD><EFBFBD> Private Key
byte[] aesKey;
byte[] aesIv;
using (var rsaDecrypt = RSA.Create())
{
rsaDecrypt.ImportPkcs8PrivateKey(Convert.FromBase64String(settings.Federation.PrivateKey!), out _);
var decryptedKeys = rsaDecrypt.Decrypt(Convert.FromBase64String(packet.EncryptedIV), RSAEncryptionPadding.Pkcs1);
// AES-256 Key (32 bytes) + IV (16 bytes)
aesKey = new byte[32];
aesIv = new byte[16];
Buffer.BlockCopy(decryptedKeys, 0, aesKey, 0, 32);
Buffer.BlockCopy(decryptedKeys, 32, aesIv, 0, 16);
}
// 3. Ðàñøèôðîâûâàåì ñàìî ñîîáùåíèå (AES-256)
// 3. <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> (AES-256)
string plainText;
using (var aes = Aes.Create())
{
@@ -96,7 +98,7 @@ internal sealed class InboundFederationCommandHandler : ICommandHandler<InboundF
}
}
// 4. Ëîãèêà îáðàáîòêè òèïîâ ñîîáùåíèé
// 4. <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
if (packet.Metadata.MessageType == "sync_capabilities")
{
var capabilities = JsonSerializer.Deserialize<RemoteCapabilities>(plainText);
@@ -112,14 +114,16 @@ internal sealed class InboundFederationCommandHandler : ICommandHandler<InboundF
{
var statusData = JsonSerializer.Deserialize<JsonElement>(plainText);
var isOnline = statusData.GetProperty("IsOnline").GetBoolean();
var user = await _userRepository.GetByIdAsync(packet.Metadata.SenderId, cancellationToken);
if (user != null && user.IsExternal)
{
user.IsOnline = isOnline;
await _userRepository.UpdateAsync(user, cancellationToken);
// Óâåäîìëÿåì ëîêàëüíûõ ïîëüçîâàòåëåé ÷åðåç SignalR
// <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> SignalR
await _notifier.NotifyNewMessageAsync(Guid.Empty, new { type = "presence_update", userId = user.Id, isOnline = user.IsOnline }, cancellationToken);
}
return Result.Success();
@@ -163,12 +167,19 @@ internal sealed class InboundFederationCommandHandler : ICommandHandler<InboundF
{
await _reactionRepository.RemoveAsync(messageForReaction.Id, packet.Metadata.UserId, plainText, cancellationToken);
}
await _notifier.NotifyNewMessageAsync(messageForReaction.ChatId, new {
type = packet.Metadata.MessageType,
messageId = messageForReaction.Id,
userId = packet.Metadata.UserId,
emoji = plainText
await _notifier.NotifyNewMessageAsync(messageForReaction.ChatId, new
{
type = packet.Metadata.MessageType,
messageId = messageForReaction.Id,
userId = packet.Metadata.UserId,
emoji = plainText
}, cancellationToken);
}
return Result.Success();
@@ -183,21 +194,26 @@ internal sealed class InboundFederationCommandHandler : ICommandHandler<InboundF
if (packet.Metadata.MessageType == "poll" && !settings.Messages.AllowPolls)
return Result.Failure(new Error("Federation.PollsDisabled", "We do not accept polls."));
// 5. Ñîõðàíåíèå â áàçó ñîîáùåíèé (MongoDB)
// 5. <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20> <20><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> (MongoDB)
var message = new TextMessage(
Guid.NewGuid(),
packet.Metadata.ChatId,
packet.Metadata.SenderId,
plainText,
Guid.NewGuid(),
packet.Metadata.ChatId,
packet.Metadata.SenderId,
plainText,
null,
null,
null,
packet.Metadata.CreatedAt,
packet.Metadata.CreatedAt,
false);
_messageRepository.Add(message);
// 6. Óâåäîìëåíèå ïîëüçîâàòåëÿ ÷åðåç SignalR
// 6. <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> SignalR
await _notifier.NotifyNewMessageAsync(packet.Metadata.ChatId, message, cancellationToken);
return Result.Success();

View File

@@ -3,9 +3,9 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Modules.Messaging.Domain;
using Knot.Contracts.Messaging.Domain;
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.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Settings.Application.DTOs;

View File

@@ -2,21 +2,17 @@ using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Modules.Messaging.Domain;
using Knot.Modules.Federation.Application.Federation.Services;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Federation.Application.Abstractions;
using Knot.Contracts.Auth.Domain;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Domain;
using Knot.Contracts.Settings.Application.Abstractions;
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;
/// <summary>
/// Обработчик доменного события отправки сообщения.
/// Если в чате есть внешние участники — инициирует федеративную рассылку.
/// </summary>
public sealed class MessageSentDomainEventHandler : INotificationHandler<MessageSentDomainEvent>
{
private readonly IChatRepository _chatRepository;
@@ -26,7 +22,7 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
private readonly IFederationGateway _gateway;
public MessageSentDomainEventHandler(
IChatRepository chatRepository,
IChatRepository chatRepository,
IUserRepository userRepository,
ISettingsService settingsService,
FederationPacketService packetService,
@@ -44,14 +40,12 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
var settings = await _settingsService.GetSettingsAsync(cancellationToken);
if (!settings.Federation.Enabled) return;
// 1. Загружаем чат и проверяем наличие внешних участников
var chat = await _chatRepository.GetByIdAsync(notification.ChatId, cancellationToken);
if (chat == null) return;
var memberIds = chat.Members.Select(m => m.UserId).ToList();
var members = await _userRepository.GetByIdsAsync(memberIds, cancellationToken);
// Находим уникальные домены внешних участников
var externalDomains = members
.Where(u => u.IsExternal && !string.IsNullOrEmpty(u.Domain))
.Select(u => u.Domain!)
@@ -60,7 +54,6 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
if (!externalDomains.Any()) return;
// 2. Получаем имя отправителя для метаданных
var sender = await _userRepository.GetByIdAsync(notification.SenderId, cancellationToken);
var senderUsername = sender?.Username ?? "unknown";
@@ -68,22 +61,20 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
notification.ChatId,
notification.SenderId,
senderUsername,
"text", // Для начала поддерживаем только текст через ивент
"text",
DateTime.UtcNow
);
// 3. Рассылка по доменам (Fan-out)
foreach (var domain in externalDomains)
{
var packetResult = await _packetService.PreparePacketAsync(
notification.Content ?? string.Empty,
metadata,
domain,
notification.Content ?? string.Empty,
metadata,
domain,
cancellationToken);
if (packetResult.IsSuccess)
{
// Отправляем асинхронно
await _gateway.SendPacketAsync(packetResult.Value, domain, cancellationToken);
}
}

View File

@@ -3,7 +3,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Modules.Settings.Domain.Events;
using Knot.Contracts.Settings.Domain;
using Knot.Modules.Federation.Application.Abstractions;
using Knot.Modules.Federation.Application.Federation.Services;
using Knot.Contracts.Settings.Application.Abstractions;

View File

@@ -4,8 +4,7 @@ using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Contracts.Auth.Domain;
using Knot.Modules.Auth.Domain;
using Knot.Modules.Conversations.Domain;
using Knot.Contracts.Conversations.Domain;
using Knot.Modules.Federation.Application.Abstractions;
using Knot.Modules.Federation.Application.Federation.Services;
using Knot.Contracts.Settings.Application.Abstractions;
@@ -14,8 +13,8 @@ using Knot.Contracts.Settings.Application.DTOs;
namespace Knot.Modules.Federation.Application.Federation.Events;
/// <summary>
/// Îáðàáîò÷èê èçìåíåíèÿ ñòàòóñà ïîëüçîâàòåëÿ.
/// Ðàññûëàåò íîâûé ñòàòóñ âñåì ñåðâåðàì, ãäå ó ïîëüçîâàòåëÿ åñòü ÷àòû.
/// Обработчик изменения статуса пользователя.
/// Отправляет статус всем внешним контактам, а также подписчикам чатов.
/// </summary>
public sealed class UserStatusChangedDomainEventHandler : INotificationHandler<UserStatusChangedDomainEvent>
{
@@ -44,11 +43,11 @@ public sealed class UserStatusChangedDomainEventHandler : INotificationHandler<U
var settings = await _settingsService.GetSettingsAsync(cancellationToken);
if (!settings.Federation.Enabled) return;
// 1. Íàõîäèì âñå ÷àòû ïîëüçîâàòåëÿ
// 1. Получаем все чаты пользователя
var userChats = await _chatRepository.GetUserChatsAsync(notification.UserId, cancellationToken);
if (!userChats.Any()) return;
// 2. Îïðåäåëÿåì óíèêàëüíûå âíåøíèå äîìåíû ó÷àñòíèêîâ ýòèõ ÷àòîâ
// 2. Получаем уникальные ID участников всех чатов пользователя
var allMemberIds = userChats.SelectMany(c => c.Members.Select(m => m.UserId)).Distinct().ToList();
var members = await _userRepository.GetByIdsAsync(allMemberIds, cancellationToken);
@@ -60,7 +59,7 @@ public sealed class UserStatusChangedDomainEventHandler : INotificationHandler<U
if (!externalDomains.Any()) return;
// 3. Ôîðìèðóåì ïàêåò ñòàòóñà
// 3. Формируем данные статуса
var statusData = new {
notification.IsOnline,
notification.LastSeen
@@ -75,7 +74,7 @@ public sealed class UserStatusChangedDomainEventHandler : INotificationHandler<U
DateTime.UtcNow
);
// 4. Ðàññûëêà ïî äîìåíàì
// 4. Отправляем по доменам
foreach (var domain in externalDomains)
{
var packetResult = await _packetService.PreparePacketAsync(payload, metadata, domain, cancellationToken);

View File

@@ -3,16 +3,18 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Temporarily disabled due to architecture violations - uses Knot.Modules.* instead of Contracts -->
<GenerateTargetFrameworkAttribute>false</GenerateTargetFrameworkAttribute>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\..\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj" />
<ProjectReference Include="..\..\Contracts\Auth\Knot.Contracts.Auth.csproj" />
<ProjectReference Include="..\..\Contracts\Settings\Knot.Contracts.Settings.csproj" />
<ProjectReference Include="..\Auth\Knot.Modules.Auth.csproj" />
<ProjectReference Include="..\Conversations\Knot.Modules.Conversations.csproj" />
<ProjectReference Include="..\Messaging\Knot.Modules.Messaging.csproj" />
<ProjectReference Include="..\Stories\Knot.Modules.Stories.csproj" />
<ProjectReference Include="..\..\Contracts\Messaging\Knot.Contracts.Messaging.csproj" />
<ProjectReference Include="..\..\Contracts\Conversations\Knot.Contracts.Conversations.csproj" />
<ProjectReference Include="..\..\Contracts\Profiles\Knot.Contracts.Profiles.csproj" />
<ProjectReference Include="..\..\Contracts\Stories\Knot.Contracts.Stories.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Carter" Version="10.0.0" />

View File

@@ -1,36 +1,27 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Knot.Modules.Messaging.Domain;
using Knot.Shared.Kernel;
using MongoDB.Driver;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Messaging.Infrastructure.Persistence;
using Knot.Modules.Messaging.Infrastructure.Persistence.Mongo;
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Shared.Kernel;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Driver;
namespace Knot.Modules.Messaging;
public static class DependencyInjection
{
/// <summary>
/// ╨а╨╡╨│╨╕╤Б╤В╤А╨░╤Ж╨╕╤П ╤Б╨╡╤А╨▓╨╕╤Б╨╛╨▓ ╨╝╨╛╨┤╤Г╨╗╤П Chats.
/// </summary>
public static IServiceCollection AddMessagingModule(
this IServiceCollection services,
IConfiguration configuration)
{
// ╨Э╨░╤Б╤В╤А╨╛╨╣╨║╨░ ╨▒╨░╨╖╤Л ╨┤╨░╨╜╨╜╤Л╤Е
string connectionString = configuration.GetConnectionString("DefaultConnection")!;
// MongoDB Setup for Messages
MongoDbMapConfigurator.Configure();
var mongoConnectionString = configuration.GetConnectionString("MongoConnection") ?? "mongodb://localhost:27017";
services.AddSingleton<IMongoClient>(new MongoClient(mongoConnectionString));
services.AddScoped<IMongoDatabase>(sp =>
services.AddScoped<IMongoDatabase>(sp =>
sp.GetRequiredService<IMongoClient>().GetDatabase("forkmessager_chats"));
// Registration

View File

@@ -1,15 +1,10 @@
using MediatR;
using Knot.Modules.Messaging.Domain;
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Messaging.Infrastructure.Handlers;
/// <summary>
/// Обработчик доменного события отправки сообщения.
/// Отправляет уведомление через SignalR всем участникам чата.
/// </summary>
public sealed class MessageSentDomainEventHandler : INotificationHandler<MessageSentDomainEvent>
{
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 = message.SenderId, Username = "unknown", DisplayName = "Unknown", Avatar = (string?)null };
// Fetch forwarded from info if exists
object? forwardedFromObj = null;
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 };
}
// Fetch reply info if exists
object? replyToObj = null;
if (message.ReplyToId.HasValue)
{
@@ -62,7 +55,7 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
Id = replyMsg.Id,
Content = replyMsg.Content,
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
? new { Id = replySenderInfo.Id, Username = replySenderInfo.Username, DisplayName = replySenderInfo.DisplayName }
: new { Id = replyMsg.SenderId, Username = "unknown", DisplayName = "Unknown" }
@@ -70,34 +63,32 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
}
}
// Отправляем сообщение в "комнату" чата.
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,
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 = 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);
type = m.Type,
url = m.Url,
filename = m.FileId,
size = m.Size
}).ToList(),
sender = senderObj,
readBy = new List<object>(),
storyId = message.StoryId,
storyMediaUrl = message.StoryMediaUrl,
storyMediaType = message.StoryMediaType
}, cancellationToken);
}
}

View File

@@ -1,5 +1,6 @@
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using MongoDB.Driver;
using Knot.Modules.Messaging.Domain;
namespace Knot.Modules.Messaging.Infrastructure.Persistence;
@@ -14,7 +15,7 @@ public sealed class MessageReactionRepository : IMessageReactionRepository
_reactions = mongoDatabase.GetCollection<MessageReaction>("message_reactions");
_mediator = mediator;
_messageRepository = messageRepository;
// Ensure index for fast querying by message
var indexKeysDefinition = Builders<MessageReaction>.IndexKeys.Ascending(r => r.MessageId);
_reactions.Indexes.CreateOne(new CreateIndexModel<MessageReaction>(indexKeysDefinition));
@@ -22,17 +23,14 @@ public sealed class MessageReactionRepository : IMessageReactionRepository
public async Task AddAsync(MessageReaction reaction, CancellationToken cancellationToken)
{
// Уникальный индекс или фильтр, чтобы не дублировать
var filter = Builders<MessageReaction>.Filter.And(
Builders<MessageReaction>.Filter.Eq(r => r.MessageId, reaction.MessageId),
Builders<MessageReaction>.Filter.Eq(r => r.UserId, reaction.UserId),
Builders<MessageReaction>.Filter.Eq(r => r.Emoji, reaction.Emoji)
);
// Используем ReplaceOptions.IsUpsert = true для идемпотентности (нет гонок)
await _reactions.ReplaceOneAsync(filter, reaction, new ReplaceOptions { IsUpsert = true }, cancellationToken);
// Уведомляем систему (для Федерации)
var message = await _messageRepository.GetByIdAsync(reaction.MessageId, cancellationToken);
if (message != null)
{
@@ -50,7 +48,6 @@ public sealed class MessageReactionRepository : IMessageReactionRepository
await _reactions.DeleteOneAsync(filter, cancellationToken);
// Уведомляем систему (для Федерации)
var message = await _messageRepository.GetByIdAsync(messageId, cancellationToken);
if (message != null)
{

View File

@@ -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 MongoDB.Bson;
using MongoDB.Driver;
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;
public sealed class MessageRepository : IMessageRepository
{
private readonly IMongoCollection<Message> _messages;
private readonly Knot.Modules.Messaging.Application.Abstractions.IChatAccessProvider _chatAccessProvider;
private readonly IChatAccessProvider _chatAccessProvider;
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");
_chatAccessProvider = chatAccessProvider;
@@ -28,7 +29,7 @@ public sealed class MessageRepository : IMessageRepository
// Publish domain events manualy for mongo entities
var events = message.GetDomainEvents().ToList();
message.ClearDomainEvents();
// This runs synchronously or without waiting, better to run async but Add is void
// In this implementation setting, fire and forget or wrap sync
foreach (var domainEvent in events)
@@ -65,7 +66,7 @@ public sealed class MessageRepository : IMessageRepository
{
var builder = Builders<Message>.Filter;
var filter = builder.Eq(m => m.ChatId, chatId);
if (cursor.HasValue)
{
filter &= builder.Lt(m => m.CreatedAt, cursor.Value);

View File

@@ -1,8 +1,8 @@
using Knot.Modules.Messaging.Domain;
using Knot.Contracts.Messaging.Domain;
using Knot.Shared.Kernel;
using MongoDB.Bson;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers;
using Knot.Shared.Kernel;
namespace Knot.Modules.Messaging.Infrastructure.Persistence.Mongo;
@@ -15,7 +15,7 @@ public static class MongoDbMapConfigurator
if (_initialized) return;
try { BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard)); } catch { /* Already registered */ }
BsonSerializer.RegisterSerializer(new EnumSerializer<MessageState>(BsonType.Int32));
BsonSerializer.RegisterSerializer(new EnumSerializer<MediaType>(BsonType.String));
@@ -45,7 +45,7 @@ public static class MongoDbMapConfigurator
cm.AutoMap();
cm.MapField("_media").SetElementName("Media");
cm.SetDiscriminator("MediaMessage");
cm.UnmapProperty(c => c.Caption); // Avoid DB duplication, Content is already saved
cm.UnmapProperty(c => c.Caption);
});
BsonClassMap.RegisterClassMap<StoryMessage>(cm =>
@@ -69,7 +69,7 @@ public static class MongoDbMapConfigurator
BsonClassMap.RegisterClassMap<PollOption>(cm => cm.AutoMap());
BsonClassMap.RegisterClassMap<PollVote>(cm => cm.AutoMap());
BsonClassMap.RegisterClassMap<Media>(cm =>
{
cm.AutoMap();

View File

@@ -3,8 +3,8 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using MongoDB.Bson;
using MongoDB.Driver;
@@ -24,7 +24,6 @@ public sealed class UserStatsService : IUserStatsService
var userGuidList = userIds.ToList();
if (!userGuidList.Any()) return new Dictionary<Guid, UserStats>();
// Эффективная агрегация: считаем количество и сумму Media.Size
var stats = await _messages.Aggregate()
.Match(Builders<Message>.Filter.In(m => m.SenderId, userGuidList))
.Group(new BsonDocument {
@@ -67,9 +66,6 @@ public sealed class UserStatsService : IUserStatsService
public async Task<long> GetOrphanedMediaSizeAsync(HashSet<string> validFileIds, CancellationToken ct = default)
{
// Для больших объемов правильнее собирать список ВСЕХ URL файлов из сообщений,
// но здесь мы оптимизируем через проекцию, чтобы вернуть только нужные поля.
// Этот метод может быть реализован в BackgroundTask для очень больших баз.
return 0; // Временная заглушка, реальный подсчет через курсор в DryRun
return 0;
}
}

View File

@@ -2,13 +2,14 @@
<ItemGroup>
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\..\Contracts\Messaging\Knot.Contracts.Messaging.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.App" Version="2.2.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.4" />
<PackageReference Include="MongoDB.Driver" Version="3.2.0" />
<ProjectReference Include="..\Settings\Knot.Modules.Settings.csproj" />
<ProjectReference Include="..\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup>
<PropertyGroup>