Импорт

This commit is contained in:
Халимов Рустам
2026-04-06 22:20:13 +03:00
parent fa185afc73
commit 1558b20470
317 changed files with 18311 additions and 924 deletions
@@ -8,7 +8,7 @@ using Knot.Contracts.Auth.Infrastructure.Persistence;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Infrastructure.Persistence;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Storage.Abstractions;
using Knot.Shared.Kernel.Storage;
using Knot.Contracts.Stories.Infrastructure.Persistence;
using Knot.Modules.Admin.Application.Admin.DTOs;
using MediatR;
@@ -51,7 +51,7 @@ internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand,
var orphanMessages = await _messageQueryService.GetOrphanedMessagesAsync(activeChatIds, cancellationToken);
var keptMessages = allMessages
.Where(m => !orphanMessages.Any(om => om.Id == m.Id))
.Where(m => !orphanMessages.Any(om => om.Id == m.Id) && !m.IsDeleted)
.ToList();
var allMinioFiles = (await _fileStorage.ListFilesAsync()).ToList();
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
@@ -8,6 +8,7 @@ using Knot.Contracts.Auth.Infrastructure.Persistence;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Infrastructure.Persistence;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Shared.Kernel.Storage;
using Knot.Contracts.Stories.Infrastructure.Persistence;
using Knot.Modules.Admin.Application.Admin.DTOs;
using MediatR;
@@ -23,17 +24,20 @@ internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery,
private readonly IAuthDbContext _authDbContext;
private readonly IChatsDbContext _chatsDbContext;
private readonly IStoryCollection _storyCollection;
private readonly IFileStorageService _fileStorage;
public CleanDryRunQueryHandler(
Knot.Contracts.Messaging.Application.Abstractions.IMessageQueryService messageService,
IAuthDbContext authDbContext,
IChatsDbContext chatsDbContext,
IStoryCollection storyCollection)
IStoryCollection storyCollection,
IFileStorageService fileStorage)
{
_messageService = messageService;
_authDbContext = authDbContext;
_chatsDbContext = chatsDbContext;
_storyCollection = storyCollection;
_fileStorage = fileStorage;
}
public async Task<Result<CleanDryRunResult>> Handle(CleanDryRunQuery request, CancellationToken ct)
@@ -46,25 +50,42 @@ internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery,
var orphanedMediaCount = orphanedMessages.Count(m => m.MediaUrl != null);
var orphanedMessageCount = orphanedMessages.Count;
var validIds = new HashSet<string>();
foreach (var msg in orphanedMessages.Where(m => m.MediaUrl != null))
{
var parts = msg.MediaUrl.Split('/');
var fileId = parts.LastOrDefault();
if (!string.IsNullOrEmpty(fileId))
{
validIds.Add(fileId);
}
}
var allMessages = await _messageService.GetAllMessagesAsync(ct);
var keptMessages = allMessages
.Where(m => !orphanedMessages.Any(om => om.Id == m.Id) && !m.IsDeleted)
.ToList();
var allUsers = await _authDbContext.Users.ToListAsync(ct);
var stories = await _storyCollection.GetAllAsync(ct);
var validUrls = new HashSet<string>();
var activeMessageUrls = keptMessages.Where(m => m.Media != null).SelectMany(m => m.Media!).Select(x => x.Url).Where(u => !string.IsNullOrEmpty(u));
var activeChatUrls = _chatsDbContext.Chats.Select(c => c.Avatar).Where(u => !string.IsNullOrEmpty(u));
var activeUserUrls = allUsers.Select(u => u.Avatar).Where(u => !string.IsNullOrEmpty(u));
var activeStoryUrls = stories.Select(s => s.MediaUrl).Where(u => !string.IsNullOrEmpty(u));
foreach (var u in activeMessageUrls) validUrls.Add(u!);
foreach (var u in activeChatUrls) validUrls.Add(u!);
foreach (var u in activeUserUrls) validUrls.Add(u!);
foreach (var u in activeStoryUrls) validUrls.Add(u!);
var validFileIds = validUrls
.Where(u => u.Contains("/api/files/"))
.Select(u => u.Split('/').Last())
.ToHashSet();
var allMinioFiles = await _fileStorage.ListFilesAsync();
long orphanedFileSize = allMinioFiles
.Where(f => !validFileIds.Contains(f.FileId))
.Sum(f => f.Size);
var expiredStoriesCount = stories.Count(s => s.ExpiresAt.HasValue && s.ExpiresAt.Value < DateTime.UtcNow);
var expiredStoriesSize = stories.Where(s => s.ExpiresAt.HasValue && s.ExpiresAt.Value < DateTime.UtcNow).Sum(s => s.MediaUrl?.Length ?? 0);
return Result.Success(new CleanDryRunResult(
orphanedMessageCount,
orphanedMediaCount,
0,
orphanedFileSize,
expiredStoriesCount,
expiredStoriesSize
));
@@ -131,6 +131,17 @@ public static class AdminEndpoints
return Results.Ok(result.Value);
});
group.MapPost("clean/run", async (ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new CleanRunCommand(), ct);
if (!result.IsSuccess)
{
Console.WriteLine($"[Admin] Cleanup Run Error: {result.Error.Description}");
return Results.BadRequest(new { error = result.Error.Description });
}
return Results.Ok(result.Value);
});
group.MapGet("timezones", () =>
{
// Получаем все системные часовые пояса и формируем удобный для фронтенда формат
@@ -1,11 +0,0 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Conversations.Application.Abstractions;
/// <summary>
/// Unit of Work специфичный для модуля Chats.
/// </summary>
public interface IChatsUnitOfWork : IUnitOfWork
{
}
@@ -1,11 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
namespace Knot.Modules.Conversations.Application.Abstractions;
public interface IUserDeleterService
{
Task<Result> DeleteUserAsync(Guid userId, CancellationToken cancellationToken);
}
@@ -1,8 +0,0 @@
using System;
namespace Knot.Modules.Conversations.Application.Abstractions;
public interface IUserStatusService
{
bool IsUserOnline(string userId);
}
@@ -4,8 +4,8 @@ using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using MediatR;
using System.Linq;
using SixLabors.ImageSharp;
@@ -2,8 +2,8 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using MediatR;
using System.Linq;
@@ -1,7 +1,7 @@
using Knot.Modules.Conversations.Domain;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Application.Abstractions;
namespace Knot.Modules.Conversations.Application.Chats.Create;
@@ -5,9 +5,9 @@ using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Domain;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using MediatR;
@@ -5,9 +5,9 @@ using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Domain;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using MediatR;
@@ -147,7 +147,9 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
chat.CreatedAt,
members,
messagesList,
unreadCount
unreadCount,
chat.IsImporting,
chat.ImportJobId
));
}
@@ -1,6 +1,6 @@
using Knot.Modules.Conversations.Domain;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Application.Abstractions;
namespace Knot.Modules.Conversations.Application.Chats.GetOrCreateFavorites;
@@ -1,12 +1,13 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using MediatR;
using System.Linq;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Shared.Kernel.Storage;
namespace Knot.Modules.Conversations.Application.Chats.LeaveOrDelete;
@@ -15,11 +16,19 @@ public record LeaveOrDeleteChatCommand(Guid ChatId, Guid UserId) : ICommand<Succ
internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrDeleteChatCommand, SuccessResponse>
{
private readonly IChatRepository _chatRepository;
private readonly IMessageRepository _messageRepository;
private readonly IFileStorageService _fileStorage;
private readonly IChatsUnitOfWork _uow;
public LeaveOrDeleteChatCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
public LeaveOrDeleteChatCommandHandler(
IChatRepository chatRepository,
IMessageRepository messageRepository,
IFileStorageService fileStorage,
IChatsUnitOfWork uow)
{
_chatRepository = chatRepository;
_messageRepository = messageRepository;
_fileStorage = fileStorage;
_uow = uow;
}
@@ -36,13 +45,18 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
return Result.Failure<SuccessResponse>(ChatErrors.Unauthorized);
}
if (chat.Type == ChatType.Group)
// If it's a private chat or the last member leaving a group, delete everything
bool shouldDeleteEverything = chat.Type != ChatType.Group || chat.Members.Count <= 1;
if (chat.Type == ChatType.Group && !shouldDeleteEverything)
{
chat.RemoveMember(request.UserId);
_chatRepository.Update(chat);
}
else
{
// DELETE ALL MESSAGES AND FILES FIRST
await DeleteChatMediaAndMessagesAsync(chat.Id, cancellationToken);
_chatRepository.Remove(chat);
}
@@ -50,5 +64,45 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
return Result.Success(new SuccessResponse(true));
}
}
private async Task DeleteChatMediaAndMessagesAsync(Guid chatId, CancellationToken ct)
{
try
{
// Get all messages directly from Mongo (not paged)
var messages = await _messageRepository.GetChatMessagesAsync(chatId, int.MaxValue, 0, ct);
foreach (var msg in messages)
{
if (msg is Knot.Contracts.Messaging.Domain.MediaMessage mediaMsg)
{
foreach (var media in mediaMsg.Media)
{
if (!string.IsNullOrEmpty(media.Url))
{
var fileId = ExtractFileId(media.Url);
if (!string.IsNullOrEmpty(fileId))
{
await _fileStorage.DeleteFileAsync(fileId);
}
}
}
}
}
await _messageRepository.DeleteChatMessagesAsync(chatId, ct);
}
catch (Exception ex)
{
// Log if possible, but don't fail chat deletion
Console.WriteLine($"[Cleanup] Error deleting chat media: {ex.Message}");
}
}
private string? ExtractFileId(string url)
{
var lastSlash = url.LastIndexOf('/');
if (lastSlash == -1) return null;
var id = url[(lastSlash + 1)..];
if (id.Contains('?')) id = id[..id.IndexOf('?')];
return id;
}
}
@@ -3,8 +3,8 @@ using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using MediatR;
using System.Linq;
@@ -2,8 +2,8 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.DTOs;
using MediatR;
using System.Linq;
@@ -2,8 +2,8 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using MediatR;
using System.Linq;
@@ -12,6 +12,8 @@ public record ChatDto(
DateTime CreatedAt,
List<ChatMemberDto> Members,
List<ChatMessageDto> Messages,
int UnreadCount
int UnreadCount,
bool IsImporting = false,
Guid? ImportJobId = null
);
@@ -1,4 +1,4 @@
using Knot.Modules.Conversations.Domain;
using Knot.Contracts.Conversations.Domain;
using System;
namespace Knot.Modules.Conversations.Application.DTOs;
@@ -1,6 +1,6 @@
using System;
using System.Collections.Generic;
using Knot.Modules.Conversations.Domain;
using Knot.Contracts.Conversations.Domain;
namespace Knot.Modules.Conversations.Application.DTOs;
@@ -1,5 +1,5 @@
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Shared.Kernel;
using MediatR;
@@ -1,5 +1,5 @@
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Shared.Kernel;
using MediatR;
@@ -1,5 +1,5 @@
using global::Knot.Modules.Conversations.Application.Abstractions;
using global::Knot.Modules.Conversations.Domain;
using global::Knot.Contracts.Conversations.Application.Abstractions;
using global::Knot.Contracts.Conversations.Domain;
using global::Knot.Modules.Conversations.Infrastructure.SignalR;
using global::Knot.Shared.Kernel;
using MediatR;
@@ -5,9 +5,9 @@ using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Domain;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using MediatR;
@@ -39,12 +39,21 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
}
DateTime? cursorDate = null;
if (!string.IsNullOrEmpty(request.Cursor) && DateTime.TryParse(request.Cursor, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed))
long? cursorSequenceId = null;
if (!string.IsNullOrEmpty(request.Cursor))
{
cursorDate = parsed.ToUniversalTime();
if (long.TryParse(request.Cursor, out var seqId))
{
cursorSequenceId = seqId;
}
else if (DateTime.TryParse(request.Cursor, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed))
{
cursorDate = parsed.ToUniversalTime();
}
}
var messages = await _messageRepository.GetChatMessagesCursorAsync(request.ChatId, cursorDate, ChatConstants.DefaultMessageQueryLimit, cancellationToken);
var messages = await _messageRepository.GetChatMessagesCursorAsync(request.ChatId, cursorDate, cursorSequenceId, ChatConstants.DefaultMessageQueryLimit, cancellationToken);
var result = new List<MessageDetailDto>();
var userIdsToFetch = new HashSet<Guid>();
@@ -88,7 +97,8 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
ReplyToMessageDto? replyToObj = null;
if (message.ReplyToId.HasValue && replyMessages.TryGetValue(message.ReplyToId.Value, out var replyMsg))
{
var senderObj = senders.TryGetValue(replyMsg.SenderId, out var rs)
senders.TryGetValue(replyMsg.SenderId, out var rs);
var senderObj = rs != null
? new MessageSenderDto(rs.Id, rs.Username, rs.DisplayName, rs.Avatar)
: null;
@@ -135,7 +145,9 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
message.CreatedAt,
message.SequenceId,
message.ForwardedFromId,
message.ForwardedFromId.HasValue && senders.TryGetValue(message.ForwardedFromId.Value, out var fwdUser) ? new MessageSenderDto(fwdUser.Id, fwdUser.Username, fwdUser.DisplayName, fwdUser.Avatar) : null,
message.ForwardedFromId.HasValue && senders.TryGetValue(message.ForwardedFromId.Value, out var fwdUser)
? new MessageSenderDto(fwdUser.Id, fwdUser.Username, fwdUser.DisplayName, fwdUser.Avatar)
: null,
storyMessage?.StoryId,
storyMessage?.StoryMediaUrl,
storyMessage?.StoryMediaType,
@@ -6,9 +6,9 @@ using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Domain;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using MediatR;
@@ -88,7 +88,12 @@ internal sealed class GetSharedMediaQueryHandler : IQueryHandler<GetSharedMediaQ
var filteredMedia = messageMedia.Where(media =>
{
var mType = media.Type?.ToLower() ?? "file";
var isGif = mType == "image" && media.Url != null && (media.Url.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase) || media.Url.EndsWith(".gif", StringComparison.OrdinalIgnoreCase));
var filename = media.Filename?.ToLower() ?? "";
var url = media.Url?.ToLower() ?? "";
var isGif = mType == "gif" ||
(mType == "image" && (filename.EndsWith(".mp4") || filename.EndsWith(".gif") || url.EndsWith(".gif") || filename.Contains("gif"))) ||
(mType == "video" && (filename.Contains("animation") || filename.Contains("gif")));
if (filterType == "gifs")
{
@@ -1,7 +1,7 @@
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Modules.Conversations.Infrastructure.SignalR;
using Knot.Shared.Kernel;
using MediatR;
@@ -1,7 +1,7 @@
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Modules.Conversations.Infrastructure.SignalR;
using Knot.Shared.Kernel;
using MediatR;
@@ -1,7 +1,7 @@
using MediatR;
using Knot.Modules.Conversations.Domain;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Application.Abstractions;
namespace Knot.Modules.Conversations.Application.Messages.Read;
@@ -6,7 +6,7 @@ using System.Threading.Tasks;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Domain;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using MediatR;
@@ -1,8 +1,8 @@
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.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
namespace Knot.Modules.Conversations.Application.Messages.Send;
@@ -1,4 +1,4 @@
using Knot.Modules.Conversations.Domain;
using Knot.Contracts.Conversations.Domain;
using System;
using System.IO;
using System.Threading;
@@ -2,8 +2,8 @@ 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.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using MediatR;
@@ -55,15 +55,18 @@ internal sealed class DeleteUserCommandHandler : ICommandHandler<DeleteUserComma
{
foreach (var media in mediaMsg.Media)
{
bool isUsedElsewhere = allMessages.Any(m => m.Id != msg.Id &&
m is MediaMessage mm && mm.Media.Any(ame => ame.Url == media.Url));
if (!isUsedElsewhere)
if (!string.IsNullOrEmpty(media.Url))
{
var fileId = ExtractFileId(media.Url);
if (!string.IsNullOrEmpty(fileId))
bool isUsedElsewhere = allMessages.Any(m => m.Id != msg.Id &&
m is MediaMessage mm && mm.Media.Any(ame => !string.IsNullOrEmpty(ame.Url) && ame.Url == media.Url));
if (!isUsedElsewhere)
{
await _fileStorage.DeleteFileAsync(fileId);
var fileId = ExtractFileId(media.Url);
if (!string.IsNullOrEmpty(fileId))
{
await _fileStorage.DeleteFileAsync(fileId);
}
}
}
}
@@ -1,16 +1,14 @@
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Infrastructure.Persistence;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Domain;
using Knot.Contracts.Conversations.Domain;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
using Knot.Modules.Conversations.Infrastructure.Services;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ConversationsAbstractions = Knot.Modules.Conversations.Application.Abstractions;
using Knot.Contracts.Messaging.Application.Abstractions;
namespace Knot.Modules.Conversations;
@@ -29,21 +27,22 @@ public static class DependencyInjection
var mongoConnectionString = configuration.GetConnectionString("MongoConnection") ?? "mongodb://localhost:27017";
services.AddScoped<ConversationsAbstractions.IChatsUnitOfWork>(sp => sp.GetRequiredService<ChatsDbContext>());
services.AddScoped<IChatsUnitOfWork>(sp => sp.GetRequiredService<ChatsDbContext>());
services.AddScoped<Knot.Contracts.Conversations.Infrastructure.Persistence.IChatsDbContext>(sp => sp.GetRequiredService<ChatsDbContext>());
services.AddScoped<IChatRepository, ChatRepository>();
services.AddScoped<IFolderRepository, FolderRepository>();
services.AddScoped<IUserChatSettingsRepository, UserChatSettingsRepository>();
services.AddScoped<IUserFolderSettingsRepository, UserFolderSettingsRepository>();
services.AddScoped<Knot.Contracts.Conversations.Domain.IUserFolderSettingsRepository, UserFolderSettingsRepository>();
services.AddMediatR(config =>
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
services.AddScoped<Knot.Contracts.Messaging.Application.Abstractions.IChatAccessProvider, Knot.Modules.Conversations.Infrastructure.Services.ChatAccessProvider>();
services.AddScoped<Knot.Contracts.Messaging.Application.Abstractions.IChatAccessProvider, ChatAccessProvider>();
services.AddScoped<Knot.Contracts.Messaging.Application.Abstractions.IMessageNotifier, Knot.Modules.Conversations.Infrastructure.SignalR.MessageNotifier>();
services.AddScoped<ConversationsAbstractions.IUserStatusService, Knot.Modules.Conversations.Infrastructure.Services.UserStatusService>();
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserStatusService, Knot.Modules.Conversations.Infrastructure.Services.UserStatusService>();
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserDeleterService, Knot.Modules.Conversations.Infrastructure.Services.UserDeleterService>();
services.AddScoped<Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService, UserStatusService>();
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserStatusService, UserStatusService>();
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserDeleterService, UserDeleterService>();
return services;
}
}
@@ -1,157 +0,0 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.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;
}
/// <summary>
/// Создает личный чат между двумя пользователями.
/// </summary>
public static Chat CreatePersonal()
{
var chat = new Chat(Guid.NewGuid(), ChatType.Personal, null, null);
chat.RaiseDomainEvent(new ChatCreatedDomainEvent(chat));
return chat;
}
/// <summary>
/// Создает групповой чат.
/// </summary>
public static Chat CreateGroup(string name, string? avatar = null)
{
var chat = new Chat(Guid.NewGuid(), ChatType.Group, name, avatar);
chat.RaiseDomainEvent(new ChatCreatedDomainEvent(chat));
return chat;
}
/// <summary>
/// Фабричный метод для создания чата.
/// </summary>
public static Chat Create(string? name, ChatType type, string? avatar = null, string? description = null)
{
var chat = new Chat(Guid.NewGuid(), type, name, avatar, description);
chat.RaiseDomainEvent(new ChatCreatedDomainEvent(chat));
return chat;
}
public void AddMember(Guid userId, string role = "member")
{
if (_members.Any(m => m.UserId == userId))
{
return;
}
_members.Add(new ChatMember(Id, userId, role));
RaiseDomainEvent(new ChatMemberAddedDomainEvent(Id, userId));
}
public void RemoveMember(Guid userId)
{
var member = _members.FirstOrDefault(m => m.UserId == userId);
if (member != null)
{
_members.Remove(member);
}
}
public void UpdateName(string name) => Name = name;
public void UpdateDescription(string? description) => Description = description;
public void UpdateAvatar(string? avatarUrl) => Avatar = avatarUrl;
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; }
// For EF Core
private ChatMember() : base(Guid.Empty) { Role = "member"; }
internal ChatMember(Guid chatId, Guid userId, string role) : base(Guid.NewGuid())
{
ChatId = chatId;
UserId = userId;
Role = role;
JoinedAt = DateTime.UtcNow;
}
public void TogglePin() => IsPinned = !IsPinned;
public void UpdateReadCursor(Guid messageId, long sequenceId)
{
if (sequenceId > LastReadSequenceId)
{
LastReadMessageId = messageId;
LastReadSequenceId = sequenceId;
}
}
public void UpdateDeliveredCursor(Guid messageId)
{
LastDeliveredMessageId = messageId;
}
}
@@ -1,10 +0,0 @@
namespace Knot.Modules.Conversations.Domain;
public static class ChatConstants
{
public const int DefaultMessageQueryLimit = 100;
public const int MaxSharedMediaQueryLimit = 300;
public const int SearchMessagesLimit = 50;
public const int MaxFileUploadSizeMb = 50;
}
@@ -1,25 +0,0 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Conversations.Domain;
public static class ChatErrors
{
public static readonly Error FileEmpty = new Error("File.Empty", "No file uploaded");
public static readonly Error FileInvalidExtension = new Error("File.InvalidExtension", "Must be a ZIP archive");
public static readonly Error ImportExpired = new Error("Import.Expired", "Session not found or expired");
public static readonly Error ImportMissing = new Error("Import.Missing", "ZIP file lost");
public static readonly Error ChatNotFound = new Error("Chat.NotFound", "Chat not found or access denied");
public static readonly Error NotFound = new Error("Chat.NotFound", "Chat not found"); // Alias
public static readonly Error NotMember = new Error("Chat.NotMember", "You are not a member of this chat");
public static readonly Error ChatsForbidden = new Error("Chats.Forbidden", "Вы не являетесь участником этого чата.");
public static readonly Error MessagesNotFound = new Error("Messages.NotFound", "Message not found.");
public static readonly Error ChatsNotFound = new Error("Chats.NotFound", "Чат не найден.");
public static readonly Error Unauthorized = new Error("Chats.Unauthorized", "Access denied");
public static readonly Error FoldersDisabled = new Error("Folders.Disabled", "Folders feature is disabled by the administrator.");
public static readonly Error PollsDisabled = new Error("Polls.Disabled", "Polls are disabled by the administrator.");
public static readonly Error MediaDisabled = new Error("Media.Disabled", "Media messages are disabled by the administrator.");
public static Error ImportCreateChatFailed(string msg) => new Error("Import.CreateChatFailed", msg);
public static Error FileTooLarge(int maxMb) => new Error("File.TooLarge", $"File exceeds the maximum allowed size of {maxMb}MB.");
}
@@ -1,108 +0,0 @@
using System;
using System.Collections.Generic;
using Knot.Shared.Kernel;
namespace Knot.Modules.Conversations.Domain;
/// <summary>
/// Сущность папки для группировки чатов.
/// </summary>
public sealed class Folder : AggregateRoot<Guid>
{
public string Name { get; private set; }
public string? Icon { get; private set; } // URL из хранилища
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>
/// Настройки конкретного чата для конкретного пользователя.
/// Хранятся в PostgreSQL (связь User <-> Chat).
/// </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>
/// Глобальные настройки папок пользователя (скрытие дефолтных и т.д.).
/// Будет храниться в MongoDB.
/// </summary>
public sealed class UserFolderSettings : AggregateRoot<Guid>
{
public Guid UserId { get; private set; }
// Список ID папок, которые пользователь скрыл (только для дефолтных)
public List<Guid> HiddenDefaultFolderIds { get; private set; } = new();
// Список пользовательских папок (Guid созданных Folder)
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);
}
}
@@ -1,39 +0,0 @@
using Knot.Modules.Conversations.Domain;
namespace Knot.Modules.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);
}
@@ -1,5 +1,5 @@
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Domain;
using Knot.Contracts.Conversations.Domain;
using Knot.Modules.Conversations.Infrastructure.SignalR;
using MediatR;
using Microsoft.AspNetCore.SignalR;
@@ -1,5 +1,5 @@
using Knot.Contracts.Conversations.Domain;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Conversations.Domain;
namespace Knot.Modules.Conversations.Infrastructure.Persistence;
@@ -32,4 +32,4 @@ public class ChatsDbContextFactory : IDesignTimeDbContextFactory<ChatsDbContext>
optionsBuilder.UseNpgsql(connectionString);
return new ChatsDbContext(optionsBuilder.Options);
}
}
}
@@ -4,19 +4,18 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Infrastructure.Persistence;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Security;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using DomainChat = Knot.Modules.Conversations.Domain.Chat;
using DomainChat = Knot.Contracts.Conversations.Domain.Chat;
namespace Knot.Modules.Conversations.Infrastructure.Persistence;
public sealed class ChatsDbContext : DbContext, Knot.Modules.Conversations.Application.Abstractions.IChatsUnitOfWork, Knot.Contracts.Conversations.Infrastructure.Persistence.IChatsDbContext
public sealed class ChatsDbContext : DbContext, Knot.Contracts.Conversations.Application.Abstractions.IChatsUnitOfWork, Knot.Contracts.Conversations.Infrastructure.Persistence.IChatsDbContext
{
private readonly IMediator? _mediator;
private readonly IEncryptionService? _encryptionService;
@@ -54,6 +53,8 @@ public sealed class ChatsDbContext : DbContext, Knot.Modules.Conversations.Appli
{
builder.ToTable("Chats");
builder.HasKey(c => c.Id);
builder.Property(c => c.IsImporting);
builder.Property(c => c.ImportJobId);
builder.Property(c => c.Type).HasConversion<string>();
builder.OwnsMany(c => c.Members, mb =>
@@ -1,4 +1,4 @@
using Knot.Modules.Conversations.Domain;
using Knot.Contracts.Conversations.Domain;
using MongoDB.Bson.Serialization;
namespace Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
@@ -1,4 +1,4 @@
using Knot.Modules.Conversations.Domain;
using Knot.Contracts.Conversations.Domain;
using MongoDB.Driver;
namespace Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
@@ -18,4 +18,4 @@ public sealed class UserDeleterService : IUserDeleterService
{
return await _sender.Send(new DeleteUserCommand(userId), cancellationToken);
}
}
}
@@ -1,9 +1,11 @@
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Infrastructure.SignalR;
namespace Knot.Modules.Conversations.Infrastructure.Services;
public sealed class UserStatusService : IUserStatusService, Knot.Contracts.Conversations.Abstractions.IUserStatusService
public sealed class UserStatusService :
Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService,
Knot.Contracts.Conversations.Abstractions.IUserStatusService
{
public bool IsUserOnline(string userId)
{
@@ -8,7 +8,7 @@ using Knot.Modules.Conversations.Application.Messages.Send;
using Knot.Modules.Conversations.Application.Messages.Read;
using Knot.Modules.Conversations.Application.Messages.Delete;
using Knot.Modules.Conversations.Application.Messages.React;
using Knot.Modules.Conversations.Domain;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using Microsoft.Extensions.Caching.Memory;
using Knot.Contracts.Auth.Domain;
@@ -5,4 +5,4 @@ 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); } }
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); } }
@@ -26,7 +26,7 @@ namespace Knot.Modules.Conversations.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Conversations.Domain.Chat", b =>
modelBuilder.Entity("Knot.Contracts.Conversations.Domain.Chat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -56,9 +56,9 @@ namespace Knot.Modules.Conversations.Migrations
b.ToTable("Chats", "chats");
});
modelBuilder.Entity("Knot.Modules.Conversations.Domain.Chat", b =>
modelBuilder.Entity("Knot.Contracts.Conversations.Domain.Chat", b =>
{
b.OwnsMany("Knot.Modules.Conversations.Domain.ChatMember", "Members", b1 =>
b.OwnsMany("Knot.Contracts.Conversations.Domain.ChatMember", "Members", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -0,0 +1,166 @@
// <auto-generated />
using System;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Knot.Modules.Conversations.Migrations
{
[DbContext(typeof(ChatsDbContext))]
[Migration("20260406122703_AddIsImportingToChat")]
partial class AddIsImportingToChat
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("chats")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Contracts.Conversations.Domain.Chat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<bool>("IsImporting")
.HasColumnType("boolean");
b.Property<long>("LastMessageSequenceId")
.HasColumnType("bigint");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Chats", "chats");
});
modelBuilder.Entity("Knot.Contracts.Conversations.Domain.Folder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Icon")
.HasColumnType("text");
b.Property<bool>("IsDefault")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Folders", "chats");
});
modelBuilder.Entity("Knot.Contracts.Conversations.Domain.UserChatSettings", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChatId")
.HasColumnType("uuid");
b.Property<string>("FolderIds")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsMuted")
.HasColumnType("boolean");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "ChatId")
.IsUnique();
b.ToTable("UserChatSettings", "chats");
});
modelBuilder.Entity("Knot.Contracts.Conversations.Domain.Chat", b =>
{
b.OwnsMany("Knot.Contracts.Conversations.Domain.ChatMember", "Members", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<Guid>("ChatId")
.HasColumnType("uuid");
b1.Property<bool>("IsMuted")
.HasColumnType("boolean");
b1.Property<bool>("IsPinned")
.HasColumnType("boolean");
b1.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b1.Property<Guid?>("LastDeliveredMessageId")
.HasColumnType("uuid");
b1.Property<Guid?>("LastReadMessageId")
.HasColumnType("uuid");
b1.Property<long>("LastReadSequenceId")
.HasColumnType("bigint");
b1.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.HasKey("Id");
b1.HasIndex("ChatId", "UserId")
.IsUnique();
b1.ToTable("ChatMembers", "chats");
b1.WithOwner()
.HasForeignKey("ChatId");
});
b.Navigation("Members");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,32 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Conversations.Migrations
{
/// <inheritdoc />
public partial class AddIsImportingToChat : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsImporting",
schema: "chats",
table: "Chats",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsImporting",
schema: "chats",
table: "Chats");
}
}
}
@@ -0,0 +1,169 @@
// <auto-generated />
using System;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Knot.Modules.Conversations.Migrations
{
[DbContext(typeof(ChatsDbContext))]
[Migration("20260406123444_AddImportJobIdToChat")]
partial class AddImportJobIdToChat
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("chats")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Contracts.Conversations.Domain.Chat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<Guid?>("ImportJobId")
.HasColumnType("uuid");
b.Property<bool>("IsImporting")
.HasColumnType("boolean");
b.Property<long>("LastMessageSequenceId")
.HasColumnType("bigint");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Chats", "chats");
});
modelBuilder.Entity("Knot.Contracts.Conversations.Domain.Folder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Icon")
.HasColumnType("text");
b.Property<bool>("IsDefault")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Folders", "chats");
});
modelBuilder.Entity("Knot.Contracts.Conversations.Domain.UserChatSettings", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChatId")
.HasColumnType("uuid");
b.Property<string>("FolderIds")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsMuted")
.HasColumnType("boolean");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "ChatId")
.IsUnique();
b.ToTable("UserChatSettings", "chats");
});
modelBuilder.Entity("Knot.Contracts.Conversations.Domain.Chat", b =>
{
b.OwnsMany("Knot.Contracts.Conversations.Domain.ChatMember", "Members", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<Guid>("ChatId")
.HasColumnType("uuid");
b1.Property<bool>("IsMuted")
.HasColumnType("boolean");
b1.Property<bool>("IsPinned")
.HasColumnType("boolean");
b1.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b1.Property<Guid?>("LastDeliveredMessageId")
.HasColumnType("uuid");
b1.Property<Guid?>("LastReadMessageId")
.HasColumnType("uuid");
b1.Property<long>("LastReadSequenceId")
.HasColumnType("bigint");
b1.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.HasKey("Id");
b1.HasIndex("ChatId", "UserId")
.IsUnique();
b1.ToTable("ChatMembers", "chats");
b1.WithOwner()
.HasForeignKey("ChatId");
});
b.Navigation("Members");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,31 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Conversations.Migrations
{
/// <inheritdoc />
public partial class AddImportJobIdToChat : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "ImportJobId",
schema: "chats",
table: "Chats",
type: "uuid",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ImportJobId",
schema: "chats",
table: "Chats");
}
}
}
@@ -23,7 +23,7 @@ namespace Knot.Modules.Conversations.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Conversations.Domain.Chat", b =>
modelBuilder.Entity("Knot.Contracts.Conversations.Domain.Chat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -38,6 +38,12 @@ namespace Knot.Modules.Conversations.Migrations
b.Property<string>("Description")
.HasColumnType("text");
b.Property<Guid?>("ImportJobId")
.HasColumnType("uuid");
b.Property<bool>("IsImporting")
.HasColumnType("boolean");
b.Property<long>("LastMessageSequenceId")
.HasColumnType("bigint");
@@ -53,9 +59,61 @@ namespace Knot.Modules.Conversations.Migrations
b.ToTable("Chats", "chats");
});
modelBuilder.Entity("Knot.Modules.Conversations.Domain.Chat", b =>
modelBuilder.Entity("Knot.Contracts.Conversations.Domain.Folder", b =>
{
b.OwnsMany("Knot.Modules.Conversations.Domain.ChatMember", "Members", b1 =>
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Icon")
.HasColumnType("text");
b.Property<bool>("IsDefault")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Folders", "chats");
});
modelBuilder.Entity("Knot.Contracts.Conversations.Domain.UserChatSettings", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChatId")
.HasColumnType("uuid");
b.Property<string>("FolderIds")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsMuted")
.HasColumnType("boolean");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "ChatId")
.IsUnique();
b.ToTable("UserChatSettings", "chats");
});
modelBuilder.Entity("Knot.Contracts.Conversations.Domain.Chat", b =>
{
b.OwnsMany("Knot.Contracts.Conversations.Domain.ChatMember", "Members", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -9,7 +9,7 @@ using Knot.Modules.Conversations.Application.Chats.Members;
using Knot.Modules.Conversations.Application.Chats.TogglePin;
using Knot.Modules.Conversations.Application.Chats.Update;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Domain;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using MediatR;
using Microsoft.AspNetCore.Builder;
@@ -32,6 +32,18 @@ public sealed class MessageQueryService : IMessageQueryService
public async Task<List<MessageInfo>> GetOrphanedMessagesAsync(HashSet<Guid> activeChatIds, CancellationToken cancellationToken)
{
if (activeChatIds == null || activeChatIds.Count == 0)
{
var allMessages = await _messages.Find(_ => true).ToListAsync(cancellationToken);
return allMessages.Select(m => new MessageInfo(
m.Id,
m.ChatId,
m.State.HasFlag(MessageState.IsDeleted),
m is MediaMessage mm && mm.Media.Any() ? mm.Media.First().Url : null,
m is MediaMessage mm2 && mm2.Media.Any() ? mm2.Media.Select(media => new Contracts.Messaging.Application.Abstractions.MediaInfo(media.Url)).ToList() : null
)).ToList();
}
var builder = Builders<Message>.Filter;
var inFilter = builder.In(m => m.ChatId, activeChatIds);
var filter = builder.Not(inFilter);
@@ -62,18 +62,22 @@ public sealed class MessageRepository : IMessageRepository
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, int limit, CancellationToken cancellationToken)
public async Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, long? sequenceId, int limit, CancellationToken cancellationToken)
{
var builder = Builders<Message>.Filter;
var filter = builder.Eq(m => m.ChatId, chatId);
if (cursor.HasValue)
if (sequenceId.HasValue)
{
filter &= builder.Lt(m => m.SequenceId, sequenceId.Value);
}
else if (cursor.HasValue)
{
filter &= builder.Lt(m => m.CreatedAt, cursor.Value);
}
return await _messages.Find(filter)
.SortByDescending(m => m.CreatedAt)
.SortByDescending(m => m.SequenceId)
.Limit(limit)
.ToListAsync(cancellationToken);
}
@@ -10,4 +10,5 @@ public interface ITelegramHtmlParser
{
Task<List<TelegramMessage>> ParseMessagesAsync(Stream htmlStream, string baseDirInZip, CancellationToken ct = default);
Task<List<string>> ExtractAllUserNamesAsync(Stream htmlStream, CancellationToken ct = default);
Task<string?> ExtractGroupNameAsync(Stream htmlStream, CancellationToken ct = default);
}
@@ -10,6 +10,7 @@ using AngleSharp.Dom;
using AngleSharp.Html.Parser;
using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Settings.Application.DTOs;
using Knot.Modules.TelegramImport.Application.Abstractions;
using Knot.Shared.Kernel;
using MediatR;
@@ -25,18 +26,23 @@ public record ImportConflictDto(string Type, string Message, bool Blocked);
public record AnalyzeImportResponseDto(
Guid Token,
List<string> Names,
List<ImportConflictDto> Conflicts);
List<ImportConflictDto> Conflicts,
int TotalMessages,
string? GroupName);
public record AnalyzeImportCommand(Stream FileStream, string FileName) : ICommand<AnalyzeImportResponseDto>;
internal sealed class AnalyzeImportCommandHandler : ICommandHandler<AnalyzeImportCommand, AnalyzeImportResponseDto>
{
private readonly ISettingsService _settingsService;
private readonly ITelegramHtmlParser _htmlParser;
public AnalyzeImportCommandHandler(ISettingsService settingsService)
public AnalyzeImportCommandHandler(ISettingsService settingsService, ITelegramHtmlParser htmlParser)
{
_settingsService = settingsService;
_htmlParser = htmlParser;
}
public async Task<Result<AnalyzeImportResponseDto>> Handle(AnalyzeImportCommand request, CancellationToken cancellationToken)
{
if (request.FileStream == null || request.FileStream.Length == 0)
@@ -52,68 +58,77 @@ internal sealed class AnalyzeImportCommandHandler : ICommandHandler<AnalyzeImpor
var token = Guid.NewGuid();
var tempPath = Path.Combine(Path.GetTempPath(), $"{token}.zip");
await using (var fs = new FileStream(tempPath, FileMode.Create))
try
{
await request.FileStream.CopyToAsync(fs, cancellationToken);
}
var names = new HashSet<string>();
using (var archive = ZipFile.OpenRead(tempPath))
{
var htmlEntries = archive.Entries
.Where(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase) && e.Name.StartsWith("messages", StringComparison.OrdinalIgnoreCase));
foreach (var entry in htmlEntries)
await using (var fs = new FileStream(tempPath, FileMode.Create))
{
using var stream = entry.Open();
var parser = new HtmlParser();
var doc = parser.ParseDocument(stream);
await request.FileStream.CopyToAsync(fs, cancellationToken);
}
var messageNodes = doc.QuerySelectorAll(".message");
if (messageNodes == null)
{
continue;
}
var names = new HashSet<string>();
int totalMessages = 0;
string? groupName = null;
foreach (var node in messageNodes)
using (var archive = ZipFile.OpenRead(tempPath))
{
var htmlEntries = archive.Entries
.Where(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase) &&
(e.Name.StartsWith("messages", StringComparison.OrdinalIgnoreCase) || e.Name == "export_results.html"))
.OrderBy(e => e.FullName.Length)
.ThenBy(e => e.FullName)
.ToList();
foreach (var entry in htmlEntries)
{
var fromNameNode = node.QuerySelector(".from_name");
if (fromNameNode != null)
using var stream = entry.Open();
if (string.IsNullOrEmpty(groupName))
{
var nameNodeText = (IElement)fromNameNode.Clone();
var innerSpans = nameNodeText.QuerySelectorAll("span");
foreach (var span in innerSpans)
{
span.Remove();
}
groupName = await _htmlParser.ExtractGroupNameAsync(stream, cancellationToken);
// Reset stream position if possible? No, entry.Open() returns a new stream.
// But wait! ExtractGroupNameAsync consumess the stream!
// I'll reopen it for messages if it's the same entry.
}
var name = nameNodeText.TextContent.Trim();
if (!string.IsNullOrWhiteSpace(name))
{
names.Add(name);
}
using var stream2 = entry.Open();
var messages = await _htmlParser.ParseMessagesAsync(stream2, "", cancellationToken);
totalMessages += messages.Count;
foreach (var m in messages)
{
if (!string.IsNullOrEmpty(m.SenderName))
names.Add(m.SenderName);
}
}
}
if (names.Count == 0 && totalMessages == 0)
{
return Result.Failure<AnalyzeImportResponseDto>(new Error("TelegramImport.NoMessagesFound", "В архиве не найдено сообщений Telegram или формат HTML не распознан."));
}
// Анализ конфликтов политик
var settings = await _settingsService.GetSettingsAsync(cancellationToken);
var conflicts = new List<ImportConflictDto>();
if (!settings.Messages.AllowMedia)
conflicts.Add(new ImportConflictDto("Media", "Медиафайлы (фото/видео) отключены на сервере. Они не будут импортированы.", true));
if (!settings.Messages.AllowVoiceMessages)
conflicts.Add(new ImportConflictDto("Voice", "Голосовые сообщения запрещены администратором. Будут пропущены.", true));
if (!settings.Messages.AllowPolls)
conflicts.Add(new ImportConflictDto("Polls", "Опросы не поддерживаются текущими настройками сервера.", true));
TelegramImportState.TempZips[token] = tempPath;
return Result.Success(new AnalyzeImportResponseDto(token, names.OrderBy(x => x).ToList(), conflicts, totalMessages, groupName));
}
catch (Exception ex)
{
if (File.Exists(tempPath)) File.Delete(tempPath);
return Result.Failure<AnalyzeImportResponseDto>(new Error("TelegramImport.ProcessError", $"Ошибка при обработке архива: {ex.Message}"));
}
// Анализ конфликтов политик
var settings = await _settingsService.GetSettingsAsync(cancellationToken);
var conflicts = new List<ImportConflictDto>();
if (!settings.Messages.AllowMedia)
conflicts.Add(new ImportConflictDto("Media", "Медиафайлы (фото/видео) отключены на сервере. Они не будут импортированы.", true));
if (!settings.Messages.AllowVoiceMessages)
conflicts.Add(new ImportConflictDto("Voice", "Голосовые сообщения запрещены администратором. Будут пропущены.", true));
if (!settings.Messages.AllowPolls)
conflicts.Add(new ImportConflictDto("Polls", "Опросы не поддерживаются текущими настройками сервера.", true));
TelegramImportState.TempZips[token] = tempPath;
return Result.Success(new AnalyzeImportResponseDto(token, names.ToList(), conflicts));
}
}
@@ -6,7 +6,8 @@ namespace Knot.Modules.TelegramImport.Application.TelegramImport.DTOs;
public record ExecuteImportRequest(
Guid Token,
Dictionary<string, Guid> Mapping,
string? GroupName
string? GroupName,
int TotalMessages = 0
);
@@ -11,7 +11,8 @@ public record TelegramMessage(
string? ReplyToId = null,
string? ForwardedFrom = null,
List<TelegramMedia>? Media = null,
List<TelegramReaction>? Reactions = null
List<TelegramReaction>? Reactions = null,
long OrderIndex = 0
);
public record TelegramMedia(string FilePath, string FileName, string MimeType);
@@ -1,30 +1,47 @@
using System;
using System.Collections.Generic;
using System.IO.Compression;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Modules.TelegramImport.Application.Abstractions;
using Knot.Modules.TelegramImport.Infrastructure.Background;
using Knot.Shared.Kernel;
namespace Knot.Modules.TelegramImport.Application.TelegramImport;
public record ExecuteImportResponseDto(Guid JobId, string Status);
public record ExecuteImportResponseDto(Guid JobId, string Status, Guid ChatId);
public record ExecuteImportCommand(
Guid CurrentUserId,
Guid Token,
string? GroupName,
Dictionary<string, Guid> Mapping,
string? GroupName
int TotalMessages = 0
) : ICommand<ExecuteImportResponseDto>;
internal sealed class ExecuteImportCommandHandler : ICommandHandler<ExecuteImportCommand, ExecuteImportResponseDto>
{
private readonly TelegramImportWorker _worker;
private readonly IImportJobStore _jobStore;
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _unitOfWork;
private readonly ITelegramHtmlParser _htmlParser;
public ExecuteImportCommandHandler(TelegramImportWorker worker, IImportJobStore jobStore)
public ExecuteImportCommandHandler(
TelegramImportWorker worker,
IImportJobStore jobStore,
IChatRepository chatRepository,
IChatsUnitOfWork unitOfWork,
ITelegramHtmlParser htmlParser)
{
_worker = worker;
_jobStore = jobStore;
_chatRepository = chatRepository;
_unitOfWork = unitOfWork;
_htmlParser = htmlParser;
}
public async Task<Result<ExecuteImportResponseDto>> Handle(ExecuteImportCommand request, CancellationToken cancellationToken)
@@ -34,19 +51,66 @@ internal sealed class ExecuteImportCommandHandler : ICommandHandler<ExecuteImpor
return Result.Failure<ExecuteImportResponseDto>(new Error("Import.Expired", "Import session expired or file not found."));
}
var groupName = request.GroupName;
if (string.IsNullOrEmpty(groupName))
{
try
{
using var archive = ZipFile.OpenRead(tempPath);
var firstHtml = archive.Entries.FirstOrDefault(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase));
if (firstHtml != null)
{
using var stream = firstHtml.Open();
groupName = await _htmlParser.ExtractGroupNameAsync(stream, cancellationToken);
}
}
catch { }
}
// 1. Создаем чат сразу (синхронно), чтобы пользователь его увидел
// 1. Создаем чат сразу (синхронно), чтобы пользователь его увидел
var memberIdList = request.Mapping.Values.Where(v => v != Guid.Empty).Distinct().ToList();
if (!memberIdList.Contains(request.CurrentUserId)) memberIdList.Add(request.CurrentUserId);
var jobId = Guid.NewGuid();
// Ставим задачу в фоне. Worker сам удалит файл и обновит статус.
// Мы не ждем завершения, а возвращаем JobId мгновенно.
_ = _worker.ProcessImportAsync(request, jobId, tempPath, CancellationToken.None);
// Решаем какой тип чата: если 2 участника или 1 участник (Saved Messages)
bool isPersonal = memberIdList.Count <= 2;
var chatType = isPersonal ? ChatType.Personal : ChatType.Group;
string finalChatName = groupName ?? "Telegram Import";
if (isPersonal)
{
// Берем имя собеседника из маппинга
var otherUserId = memberIdList.FirstOrDefault(id => id != request.CurrentUserId);
var otherUserName = request.Mapping.FirstOrDefault(m => m.Value == otherUserId).Key;
if (!string.IsNullOrEmpty(otherUserName)) finalChatName = otherUserName;
}
// 2. Создание чата (в скрытом состоянии)
var chat = Chat.Create(finalChatName, chatType, isImporting: true, importJobId: jobId);
chat.AddMember(request.CurrentUserId, ChatRole.Owner);
foreach (var memberId in memberIdList)
{
if (memberId != request.CurrentUserId)
{
chat.AddMember(memberId, ChatRole.Member);
}
}
_chatRepository.Add(chat);
await _unitOfWork.SaveChangesAsync(cancellationToken);
// 3. Фоновая обработка сообщений
_ = _worker.ProcessImportAsync(request, jobId, chat.Id, tempPath, CancellationToken.None);
_jobStore.AddOrUpdate(new ImportJobInfo
{
JobId = jobId,
Status = ImportJobStatus.Queued,
TotalMessages = 0
TotalMessages = request.TotalMessages
});
return Result.Success(new ExecuteImportResponseDto(jobId, "Queued"));
return Result.Success(new ExecuteImportResponseDto(jobId, "Processing", chat.Id));
}
}
@@ -1,3 +1,6 @@
using Knot.Modules.TelegramImport.Application.Abstractions;
using Knot.Modules.TelegramImport.Infrastructure.Background;
using Knot.Modules.TelegramImport.Infrastructure.Parser;
using Microsoft.Extensions.DependencyInjection;
namespace Knot.Modules.TelegramImport;
@@ -6,6 +9,13 @@ public static class DependencyInjection
{
public static IServiceCollection AddTelegramImportModule(this IServiceCollection services)
{
services.AddSingleton<ITelegramHtmlParser, TelegramHtmlParser>();
services.AddSingleton<IImportJobStore, ImportJobStore>();
// Register worker as itself and as a hosted service
services.AddSingleton<TelegramImportWorker>();
services.AddHostedService<TelegramImportWorker>(sp => sp.GetRequiredService<TelegramImportWorker>());
return services;
}
}
@@ -1,7 +1,10 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Conversations.Application.Abstractions;
@@ -10,6 +13,7 @@ using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.TelegramImport.Application.Abstractions;
using Knot.Modules.TelegramImport.Application.TelegramImport;
using Knot.Modules.TelegramImport.Application.TelegramImport.DTOs;
using Knot.Modules.TelegramImport.Infrastructure.Background;
using Knot.Shared.Kernel.Storage;
using Microsoft.Extensions.DependencyInjection;
@@ -24,7 +28,10 @@ public class TelegramImportWorker : BackgroundService
private readonly ILogger<TelegramImportWorker> _logger;
private readonly IImportJobStore _jobStore;
public TelegramImportWorker(IServiceProvider serviceProvider, ILogger<TelegramImportWorker> logger, IImportJobStore jobStore)
public TelegramImportWorker(
IServiceProvider serviceProvider,
ILogger<TelegramImportWorker> logger,
IImportJobStore jobStore)
{
_serviceProvider = serviceProvider;
_logger = logger;
@@ -34,64 +41,203 @@ public class TelegramImportWorker : BackgroundService
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Telegram Import Worker started.");
// В реальном проекте здесь будет чтение из Channels или RabbitMQ
// Для примера оставим заглушку цикла
while (!stoppingToken.IsCancellationRequested)
{
await Task.Delay(5000, stoppingToken);
}
}
public async Task ProcessImportAsync(ExecuteImportCommand request, Guid jobId, string zipPath, CancellationToken ct)
public async Task ProcessImportAsync(ExecuteImportCommand request, Guid jobId, Guid chatId, string zipPath, CancellationToken ct)
{
using var scope = _serviceProvider.CreateScope();
var parser = scope.ServiceProvider.GetRequiredService<ITelegramHtmlParser>();
var msgRepo = scope.ServiceProvider.GetRequiredService<IMessageRepository>();
var reactionRepo = scope.ServiceProvider.GetRequiredService<IMessageReactionRepository>();
var chatRepo = scope.ServiceProvider.GetRequiredService<IChatRepository>();
var uow = scope.ServiceProvider.GetRequiredService<IChatsUnitOfWork>();
var fileStorage = scope.ServiceProvider.GetRequiredService<IFileStorageService>();
var jobInfo = new ImportJobInfo { JobId = jobId, Status = ImportJobStatus.Processing };
var jobInfo = new ImportJobInfo
{
JobId = jobId,
Status = ImportJobStatus.Processing,
TotalMessages = request.TotalMessages,
ProcessedMessages = 0
};
_jobStore.AddOrUpdate(jobInfo);
try
{
using var archive = ZipFile.OpenRead(zipPath);
var entries = archive.Entries.Where(e => e.Name.StartsWith("messages") && e.Name.EndsWith(".html")).ToList();
var chat = await chatRepo.GetByIdAsync(chatId, ct);
if (chat == null) throw new Exception("Chat not found");
// 1. Создание чата (уже было в оригинале, но здесь в фоне)
Guid targetChatId = Guid.NewGuid(); // Упростим логику для демонстрации рефакторинга
_logger.LogInformation("Processing messages for ChatId: {ChatId}, JobId: {JobId}", chatId, jobId);
foreach (var entry in entries)
// TRY TO FIND THE BEST ENCODING (UTF8 or CP866)
ZipArchive archive;
try
{
using var stream = entry.Open();
var messages = await parser.ParseMessagesAsync(stream, "", ct);
foreach (var m in messages)
// Try UTF8 first
archive = ZipFile.OpenRead(zipPath);
var messagesHtml = archive.Entries.FirstOrDefault(e => e.Name.Equals("messages.html", StringComparison.OrdinalIgnoreCase));
if (messagesHtml == null)
{
Guid senderGuid = request.Mapping.TryGetValue(m.SenderName ?? "", out var sid) ? sid : request.CurrentUserId;
var textMsg = new TextMessage(Guid.NewGuid(), targetChatId, senderGuid, m.Content, null, null, null, m.CreatedAt, true);
msgRepo.Add(textMsg);
jobInfo.ProcessedMessages++;
_jobStore.AddOrUpdate(jobInfo);
// If not found in root, maybe it's CP866
archive.Dispose();
archive = ZipFile.Open(zipPath, ZipArchiveMode.Read, Encoding.GetEncoding(866));
}
await uow.SaveChangesAsync(ct);
}
catch
{
archive = ZipFile.OpenRead(zipPath);
}
jobInfo.Status = ImportJobStatus.Completed;
using (archive)
{
var entries = archive.Entries
.Where(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase) &&
(e.Name.StartsWith("messages", StringComparison.OrdinalIgnoreCase) ||
e.Name.Equals("export_results.html", StringComparison.OrdinalIgnoreCase)))
.OrderBy(e => e.FullName.Length)
.ThenBy(e => e.FullName)
.ToList();
var allMessages = new List<Knot.Modules.TelegramImport.Application.TelegramImport.DTOs.TelegramMessage>();
foreach (var entry in entries)
{
var entryPath = entry.FullName.Replace("\\", "/");
var lastSlash = entryPath.LastIndexOf('/');
var baseDir = lastSlash >= 0 ? entryPath.Substring(0, lastSlash + 1) : "";
using var stream = entry.Open();
var messages = await parser.ParseMessagesAsync(stream, baseDir, ct);
allMessages.AddRange(messages);
}
var orderedMessages = allMessages
.OrderBy(m => m.CreatedAt)
.ThenBy(m => m.OrderIndex)
.ToList();
_logger.LogInformation("Total messages parsed: {Count}", orderedMessages.Count);
var telegramToKnotIdMap = new Dictionary<string, Guid>();
var processedCount = 0;
foreach (var m in orderedMessages)
{
Guid senderId = request.Mapping.TryGetValue(m.SenderName ?? "", out var sid) ? sid : request.CurrentUserId;
Guid? replyToId = m.ReplyToId != null && telegramToKnotIdMap.TryGetValue(m.ReplyToId, out var rid) ? rid : null;
Guid knotMsgId = Guid.NewGuid();
Message? knotMsg = null;
if (m.Media != null && m.Media.Any())
{
var mediaType = m.Media.Any(mi => mi.MimeType.StartsWith("image")) ? MediaType.Image :
m.Media.Any(mi => mi.MimeType.StartsWith("video")) ? MediaType.Video :
m.Media.Any(mi => mi.MimeType.Contains("voice")) ? MediaType.Voice : MediaType.File;
var mediaMsg = new MediaMessage(knotMsgId, chat.Id, senderId, mediaType, m.Content, replyToId, null, m.CreatedAt, true);
foreach (var mediaItem in m.Media)
{
var targetPath = mediaItem.FilePath.Replace("\\", "/").TrimStart('/');
var unescapedPath = Uri.UnescapeDataString(targetPath);
var zipEntry = archive.Entries.FirstOrDefault(e => {
var entryPath = e.FullName.Replace("\\", "/").TrimStart('/');
return entryPath.Equals(targetPath, StringComparison.OrdinalIgnoreCase) ||
entryPath.Equals(unescapedPath, StringComparison.OrdinalIgnoreCase);
});
// IF NOT FOUND, try a desperate match by stripping path and comparing filenames
if (zipEntry == null)
{
var fileName = Path.GetFileName(targetPath);
zipEntry = archive.Entries.FirstOrDefault(e => e.Name.Equals(fileName, StringComparison.OrdinalIgnoreCase));
}
if (zipEntry != null)
{
using var mediaStream = zipEntry.Open();
string itemType = "file";
string mimeToUpload = mediaItem.MimeType;
if (mediaItem.MimeType.Equals("image/gif", StringComparison.OrdinalIgnoreCase))
{
itemType = "gif";
if (mediaItem.FileName.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase)) mimeToUpload = "video/mp4";
}
else if (mediaItem.MimeType.StartsWith("image")) itemType = "image";
else if (mediaItem.MimeType.StartsWith("video")) itemType = "video";
else if (mediaItem.MimeType.Contains("voice") || mediaItem.MimeType.Contains("audio")) itemType = "audio";
var fileId = await fileStorage.UploadFileAsync(mediaStream, mediaItem.FileName, mimeToUpload);
mediaMsg.AddMedia(itemType, $"/api/files/{fileId}", mediaItem.FileName, zipEntry.Length);
}
else
{
_logger.LogWarning("[Import] Media entry NOT FOUND in ZIP: {Path}", targetPath);
}
}
if (mediaMsg.Media.Any()) knotMsg = mediaMsg;
}
if (knotMsg == null)
{
knotMsg = new TextMessage(knotMsgId, chat.Id, senderId, m.Content, replyToId, null, null, m.CreatedAt, true);
}
chat.IncrementSequenceId();
knotMsg.SetSequenceId(chat.LastMessageSequenceId);
msgRepo.Add(knotMsg);
if (m.Reactions != null)
{
foreach (var r in m.Reactions)
{
foreach (var name in r.UserNames)
{
var userId = request.Mapping.TryGetValue(name, out var mappedId) && mappedId != Guid.Empty ? mappedId : request.CurrentUserId;
var knotReaction = new MessageReaction(knotMsgId, userId, r.Emoji);
await reactionRepo.AddAsync(knotReaction, ct);
}
}
}
telegramToKnotIdMap[m.Id] = knotMsgId;
processedCount++;
if (processedCount % 50 == 0)
{
await uow.SaveChangesAsync(ct);
jobInfo.ProcessedMessages = processedCount;
_jobStore.AddOrUpdate(jobInfo);
}
}
chat.CompleteImport();
await uow.SaveChangesAsync(ct);
_logger.LogInformation("Import Completed. Messages: {Count}", processedCount);
jobInfo.ProcessedMessages = processedCount;
jobInfo.Status = ImportJobStatus.Completed;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during Telegram import process.");
jobInfo.Status = ImportJobStatus.Failed;
jobInfo.ErrorMessage = ex.Message;
}
finally
{
_jobStore.AddOrUpdate(jobInfo);
try { File.Delete(zipPath); } catch { }
try { if (File.Exists(zipPath)) File.Delete(zipPath); } catch { }
TelegramImportState.TempZips.TryRemove(request.Token, out _);
}
}
}
@@ -8,16 +8,19 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace Knot.Modules.TelegramImport.Infrastructure.Parser;
public sealed class TelegramHtmlParser : ITelegramHtmlParser
{
private readonly HtmlParser _parser;
private readonly ILogger<TelegramHtmlParser> _logger;
public TelegramHtmlParser()
public TelegramHtmlParser(ILogger<TelegramHtmlParser> logger)
{
_parser = new HtmlParser();
_logger = logger;
}
public async Task<List<string>> ExtractAllUserNamesAsync(Stream htmlStream, CancellationToken ct = default)
@@ -39,42 +42,207 @@ public sealed class TelegramHtmlParser : ITelegramHtmlParser
{
var doc = await _parser.ParseDocumentAsync(htmlStream, ct);
var messages = new List<TelegramMessage>();
string? lastSenderName = null;
var messageNodes = doc.QuerySelectorAll(".message");
foreach (var node in messageNodes)
{
var msg = ParseSingleMessage(node, baseDirInZip);
// STOP ALL MERGING OF SEPARATE NODES.
// Telegram HTML nodes represent visual bubbles. If we merge them, we break the original hierarchy
// and often misplace captions or hide media.
var msg = ParseSingleMessage(node, baseDirInZip, ref lastSenderName);
if (msg != null) messages.Add(msg);
}
return messages;
}
private TelegramMessage? ParseSingleMessage(IElement node, string baseDir)
public async Task<string?> ExtractGroupNameAsync(Stream htmlStream, CancellationToken ct = default)
{
var doc = await _parser.ParseDocumentAsync(htmlStream, ct);
var header = doc.QuerySelector(".page_header .text");
var name = header?.TextContent?.Trim();
if (string.IsNullOrEmpty(name))
{
name = doc.Title?.Replace("Chat Export with ", "")?.Trim();
}
return name;
}
private TelegramMessage? ParseSingleMessage(IElement node, string baseDir, ref string? lastSenderName)
{
if (node.ClassList.Contains("service")) return null;
try
{
var id = node.GetAttribute("id") ?? Guid.NewGuid().ToString();
var fromNameNode = node.QuerySelector(".from_name");
var senderName = fromNameNode != null ? CleanName(fromNameNode) : null;
var idAttr = node.GetAttribute("id") ?? Guid.NewGuid().ToString();
long numericId = 0;
if (idAttr.StartsWith("message")) long.TryParse(idAttr.Replace("message", ""), out numericId);
var fromNameNode = node.QuerySelector(".body > .from_name");
// Ignore forwarded from_name as message sender
if (fromNameNode != null && fromNameNode.Closest(".forwarded") != null) fromNameNode = null;
var senderName = fromNameNode != null ? CleanName(fromNameNode) : lastSenderName;
// If it's a new bubble group (no "joined"), update lastSenderName
if (senderName != null && !node.ClassList.Contains("joined")) lastSenderName = senderName;
var textNode = node.QuerySelector(".text");
var content = textNode?.TextContent?.Trim() ?? "";
// Дата (парсинг из title)
var dateNode = node.QuerySelector(".date[title]") ?? node.QuerySelector("[title]");
var dateStr = dateNode?.GetAttribute("title") ?? "";
DateTime.TryParse(dateStr.Replace("UTC", "").Trim(), out var createdAt);
DateTime createdAt = DateTime.UtcNow;
return new TelegramMessage(id, senderName, createdAt, content);
if (!string.IsNullOrEmpty(dateStr))
{
var cleanDateStr = dateStr.Trim();
var formats = new[] { "dd.MM.yyyy HH:mm:ss 'UTC'zzz", "dd.MM.yyyy HH:mm:ss", "d.MM.yyyy HH:mm:ss", "dd.M.yyyy HH:mm:ss", "d.M.yyyy HH:mm:ss" };
try
{
if (DateTimeOffset.TryParseExact(cleanDateStr, formats, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dto))
{
createdAt = dto.UtcDateTime;
}
else if (DateTimeOffset.TryParse(cleanDateStr.Replace("UTC", ""), System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dto2))
{
createdAt = dto2.UtcDateTime;
}
}
catch { }
}
var mediaList = ExtractMedia(node, baseDir);
// Replies
string? replyToId = null;
var replyNode = node.QuerySelector(".reply_to a[href^=\"#go_to_message\"]");
if (replyNode != null)
{
var href = replyNode.GetAttribute("href");
replyToId = href?.Replace("#go_to_message", "message");
}
// Forwards
string? forwardedFrom = null;
var forwardNode = node.QuerySelector(".forwarded .from_name");
if (forwardNode != null) forwardedFrom = CleanName(forwardNode);
// Reactions
var reactions = new List<TelegramReaction>();
var reactionNodes = node.QuerySelectorAll(".reactions .reaction");
foreach (var r in reactionNodes)
{
var emoji = r.QuerySelector(".emoji")?.TextContent?.Trim() ?? "";
var names = r.QuerySelectorAll(".userpics [title]")
.Select(x => x.GetAttribute("title") ?? "")
.Where(x => !string.IsNullOrEmpty(x))
.ToList();
if (!string.IsNullOrEmpty(emoji)) reactions.Add(new TelegramReaction(emoji, names));
}
return new TelegramMessage(idAttr, senderName, createdAt, content, replyToId, forwardedFrom, mediaList.Any() ? mediaList : null, reactions.Any() ? reactions : null, numericId);
}
catch { return null; }
catch (Exception ex)
{
_logger.LogError(ex, "Error parsing single Telegram message");
return null;
}
}
private List<TelegramMedia> ExtractMedia(IElement node, string baseDir)
{
var mediaList = new List<TelegramMedia>();
// Look for links that represent media containers
var allLinks = node.QuerySelectorAll("a[href]").ToList();
foreach (var link in allLinks)
{
var href = NormalizeHref(link.GetAttribute("href"));
if (href == null || !href.Contains("/")) continue;
// Skip internal pages
if (href.EndsWith(".html", StringComparison.OrdinalIgnoreCase)) continue;
var fullPath = BuildPath(baseDir, href);
if (mediaList.Any(m => m.FilePath == fullPath)) continue;
var fileName = Path.GetFileName(href);
var mimeType = GetMimeType(href);
// ANIMATION DETECTION:
// 1. Check classes (standard TG export)
bool hasGifClass = link.ClassList.Contains("animated_wrap") || link.ClassList.Contains("media_video");
// 2. Check full text of the link container for "Animation" string
// This is the most reliable way to find TG "GIFs" which are actually MP4s
bool hasAnimationText = link.TextContent.Contains("Animation", StringComparison.OrdinalIgnoreCase);
if (hasGifClass || hasAnimationText)
{
mimeType = "image/gif";
}
if (link.ClassList.Contains("media_voice_message") || href.Contains("voice_messages"))
{
mimeType = "audio/ogg";
}
mediaList.Add(new TelegramMedia(fullPath, fileName, mimeType));
}
// Alternative check for voice messages in audio tags
foreach (var audioEl in node.QuerySelectorAll("audio[src]"))
{
var href = NormalizeHref(audioEl.GetAttribute("src"));
if (href == null) continue;
var fullPath = BuildPath(baseDir, href);
if (!mediaList.Any(m => m.FilePath == fullPath))
mediaList.Add(new TelegramMedia(fullPath, Path.GetFileName(href), "audio/ogg"));
}
return mediaList;
}
private static string? NormalizeHref(string? href)
{
if (string.IsNullOrWhiteSpace(href)) return null;
if (href.Contains('#')) href = href[..href.IndexOf('#')];
if (href.Contains('?')) href = href[..href.IndexOf('?')];
return string.IsNullOrWhiteSpace(href) ? null : href.Replace("\\", "/");
}
private static string BuildPath(string baseDir, string href)
{
var normalized = href.Replace("\\", "/");
return normalized.StartsWith(baseDir, StringComparison.OrdinalIgnoreCase)
? normalized
: (baseDir.TrimEnd('/') + "/" + normalized.TrimStart('/')).Replace("//", "/");
}
private string CleanName(IElement node)
{
var clone = (IElement)node.Clone();
foreach (var span in clone.QuerySelectorAll("span")) span.Remove();
foreach (var span in clone.QuerySelectorAll("span, a, div")) span.Remove();
return clone.TextContent.Trim();
}
private string GetMimeType(string path)
{
var ext = Path.GetExtension(path).ToLowerInvariant();
return ext switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".webp" => "image/webp",
".mp4" => "video/mp4",
".mov" => "video/quicktime",
".webm" => "video/webm",
_ => "application/octet-stream"
};
}
}
@@ -1,5 +1,6 @@
using Knot.Modules.TelegramImport.Application.TelegramImport;
using Knot.Modules.TelegramImport.Application.TelegramImport.DTOs;
using Knot.Modules.TelegramImport.Infrastructure.Background;
using Knot.Shared.Kernel;
using MediatR;
using Microsoft.AspNetCore.Builder;
@@ -25,14 +26,23 @@ public static class TelegramImportEndpoints
using var stream = file.OpenReadStream();
var result = await sender.Send(new AnalyzeImportCommand(stream, file.FileName), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description ?? result.Error.Code);
}).DisableAntiforgery();
}).DisableAntiforgery().WithMetadata(new Microsoft.AspNetCore.Mvc.RequestSizeLimitAttribute(1000000000));
group.MapPost("execute", async ([FromBody] ExecuteImportRequest req, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var command = new ExecuteImportCommand(userContext.UserId, req.Token, req.Mapping, req.GroupName);
var command = new ExecuteImportCommand(userContext.UserId, req.Token, req.GroupName, req.Mapping, req.TotalMessages);
var result = await sender.Send(command, ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description ?? result.Error.Code);
});
group.MapGet("status/{jobId:guid}", (Guid jobId, IImportJobStore jobStore) =>
{
if (jobStore.TryGet(jobId, out var info))
{
return Results.Ok(info);
}
return Results.NotFound();
});
}
}