Структура

This commit is contained in:
Халимов Рустам
2026-03-30 01:45:19 +03:00
parent 89b2eeea6c
commit 9438cf1b35
55 changed files with 955 additions and 398 deletions
@@ -1,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.
/// . 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>
/// Êîìàíäà äëÿ îòïðàâêè ñîîáùåíèÿ â ÷àò.
/// .
/// </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.
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat is null)
{
return Result.Failure<Guid>(ChatErrors.ChatsNotFound);
}
// 2. Ïðîâåðÿåì, ÿâëÿåòñÿ ëè îòïðàâèòåëü ó÷àñòíèêîì
// 2. ,
if (!chat.Members.Any(m => m.UserId == request.SenderId))
{
return Result.Failure<Guid>(ChatErrors.ChatsForbidden);
}
// 3. Ñîçäàåì ñîîáùåíèå
// 3.
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. 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.
_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.
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. IV 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. (AES-256)
string plainText;
using (var aes = Aes.Create())
{
@@ -96,7 +98,7 @@ internal sealed class InboundFederationCommandHandler : ICommandHandler<InboundF
}
}
// 4. Ëîãèêà îáðàáîòêè òèïîâ ñîîáùåíèé
// 4.
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
// 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. (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. 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>