Структура
This commit is contained in:
@@ -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 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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
146
backend/src/Contracts/Conversations/Domain/Chat.cs
Normal file
146
backend/src/Contracts/Conversations/Domain/Chat.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
101
backend/src/Contracts/Conversations/Domain/Folder.cs
Normal file
101
backend/src/Contracts/Conversations/Domain/Folder.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
|
||||
<ProjectReference Include="..\Messaging\Knot.Contracts.Messaging.csproj" />
|
||||
</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);
|
||||
}
|
||||
20
backend/src/Contracts/Messaging/Domain/DeletedMessage.cs
Normal file
20
backend/src/Contracts/Messaging/Domain/DeletedMessage.cs
Normal 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) { }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
9
backend/src/Contracts/Messaging/Domain/MediaType.cs
Normal file
9
backend/src/Contracts/Messaging/Domain/MediaType.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Knot.Contracts.Messaging.Domain;
|
||||
|
||||
public enum MediaType
|
||||
{
|
||||
Image,
|
||||
Video,
|
||||
Voice,
|
||||
File
|
||||
}
|
||||
126
backend/src/Contracts/Messaging/Domain/Message.cs
Normal file
126
backend/src/Contracts/Messaging/Domain/Message.cs
Normal 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; } }
|
||||
28
backend/src/Contracts/Messaging/Domain/MessageEvents.cs
Normal file
28
backend/src/Contracts/Messaging/Domain/MessageEvents.cs
Normal 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;
|
||||
25
backend/src/Contracts/Messaging/Domain/MessageReaction.cs
Normal file
25
backend/src/Contracts/Messaging/Domain/MessageReaction.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
14
backend/src/Contracts/Messaging/Domain/MessageState.cs
Normal file
14
backend/src/Contracts/Messaging/Domain/MessageState.cs
Normal 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
|
||||
}
|
||||
23
backend/src/Contracts/Messaging/Domain/Reaction.cs
Normal file
23
backend/src/Contracts/Messaging/Domain/Reaction.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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.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!);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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); } }
|
||||
|
||||
|
||||
@@ -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); } }
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user