Перепиливание под чистый DDD
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Modules.Messaging.Domain;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp.Html.Parser;
|
||||
using AngleSharp.Dom;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
namespace Knot.Modules.TelegramImport.Application.TelegramImport;
|
||||
|
||||
public static class TelegramImportState
|
||||
{
|
||||
public static readonly ConcurrentDictionary<Guid, string> TempZips = new();
|
||||
}
|
||||
|
||||
public record AnalyzeImportResponseDto(Guid Token, List<string> Names);
|
||||
|
||||
public record AnalyzeImportCommand(Stream FileStream, string FileName) : ICommand<AnalyzeImportResponseDto>;
|
||||
|
||||
internal sealed class AnalyzeImportCommandHandler : ICommandHandler<AnalyzeImportCommand, AnalyzeImportResponseDto>
|
||||
{
|
||||
public async Task<Result<AnalyzeImportResponseDto>> Handle(AnalyzeImportCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.FileStream == null || request.FileStream.Length == 0)
|
||||
{
|
||||
return Result.Failure<AnalyzeImportResponseDto>(ChatErrors.FileEmpty);
|
||||
}
|
||||
|
||||
if (!request.FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Result.Failure<AnalyzeImportResponseDto>(ChatErrors.FileInvalidExtension);
|
||||
}
|
||||
|
||||
var token = Guid.NewGuid();
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"{token}.zip");
|
||||
|
||||
await using (var fs = new FileStream(tempPath, FileMode.Create))
|
||||
{
|
||||
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)
|
||||
{
|
||||
using var stream = entry.Open();
|
||||
var parser = new HtmlParser();
|
||||
var doc = parser.ParseDocument(stream);
|
||||
|
||||
var messageNodes = doc.QuerySelectorAll(".message");
|
||||
if (messageNodes == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var node in messageNodes)
|
||||
{
|
||||
var fromNameNode = node.QuerySelector(".from_name");
|
||||
if (fromNameNode != null)
|
||||
{
|
||||
var nameNodeText = (IElement)fromNameNode.Clone();
|
||||
var innerSpans = nameNodeText.QuerySelectorAll("span");
|
||||
foreach (var span in innerSpans)
|
||||
{
|
||||
span.Remove();
|
||||
}
|
||||
|
||||
var name = nameNodeText.TextContent.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
names.Add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TelegramImportState.TempZips[token] = tempPath;
|
||||
|
||||
return Result.Success(new AnalyzeImportResponseDto(token, names.ToList()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Knot.Modules.TelegramImport.Application.TelegramImport.DTOs;
|
||||
|
||||
public record AnalyzeImportResponseDto(
|
||||
Guid Token,
|
||||
List<string> Names
|
||||
);
|
||||
|
||||
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Knot.Modules.TelegramImport.Application.TelegramImport.DTOs;
|
||||
|
||||
public record ExecuteImportRequest(
|
||||
Guid Token,
|
||||
Dictionary<string, Guid> Mapping,
|
||||
string? GroupName
|
||||
);
|
||||
|
||||
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
|
||||
namespace Knot.Modules.TelegramImport.Application.TelegramImport.DTOs;
|
||||
|
||||
public record ExecuteImportResponseDto(
|
||||
bool Success,
|
||||
int MessagesImported,
|
||||
Guid ChatId
|
||||
);
|
||||
|
||||
|
||||
|
||||
+500
@@ -0,0 +1,500 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp.Dom;
|
||||
using AngleSharp.Html.Parser;
|
||||
using MediatR;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Shared.Kernel.Storage;
|
||||
using Knot.Modules.Messaging.Domain;
|
||||
using Knot.Modules.Messaging.Application.Abstractions;
|
||||
using Knot.Modules.Conversations.Application.Chats.Create;
|
||||
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Modules.Messaging.Application.Abstractions;
|
||||
namespace Knot.Modules.TelegramImport.Application.TelegramImport;
|
||||
|
||||
public record ExecuteImportResponseDto(bool Success, int MessagesImported, Guid ChatId);
|
||||
|
||||
public record ExecuteImportCommand(
|
||||
Guid CurrentUserId,
|
||||
Guid Token,
|
||||
Dictionary<string, Guid> Mapping,
|
||||
string? GroupName
|
||||
) : ICommand<ExecuteImportResponseDto>;
|
||||
|
||||
internal sealed class ExecuteImportCommandHandler : ICommandHandler<ExecuteImportCommand, ExecuteImportResponseDto>
|
||||
{
|
||||
private readonly ISender _sender;
|
||||
private readonly IChatsUnitOfWork _uow;
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
private readonly IFileStorageService _fileStorage;
|
||||
private readonly IHubContext<ChatHub> _hubContext;
|
||||
private readonly IMessageReactionRepository _reactionRepository;
|
||||
|
||||
public ExecuteImportCommandHandler(
|
||||
ISender sender,
|
||||
IChatsUnitOfWork uow,
|
||||
IChatRepository chatRepository,
|
||||
IMessageRepository messageRepository,
|
||||
IFileStorageService fileStorage,
|
||||
IHubContext<ChatHub> hubContext,
|
||||
IMessageReactionRepository reactionRepository)
|
||||
{
|
||||
_sender = sender;
|
||||
_uow = uow;
|
||||
_chatRepository = chatRepository;
|
||||
_messageRepository = messageRepository;
|
||||
_fileStorage = fileStorage;
|
||||
_hubContext = hubContext;
|
||||
_reactionRepository = reactionRepository;
|
||||
}
|
||||
|
||||
public async Task<Result<ExecuteImportResponseDto>> Handle(ExecuteImportCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TelegramImportState.TempZips.TryGetValue(request.Token, out var tempPath))
|
||||
{
|
||||
return Result.Failure<ExecuteImportResponseDto>(ChatErrors.ImportExpired);
|
||||
}
|
||||
|
||||
if (!System.IO.File.Exists(tempPath))
|
||||
{
|
||||
return Result.Failure<ExecuteImportResponseDto>(ChatErrors.ImportMissing);
|
||||
}
|
||||
|
||||
var myId = request.CurrentUserId;
|
||||
var targetUserIds = request.Mapping.Values.Distinct().Where(id => id != Guid.Empty).ToList();
|
||||
if (!targetUserIds.Contains(myId))
|
||||
{
|
||||
targetUserIds.Add(myId);
|
||||
}
|
||||
|
||||
Guid chatId = Guid.Empty;
|
||||
var chatMembers = targetUserIds;
|
||||
|
||||
if (chatMembers.Count <= 2)
|
||||
{
|
||||
var existingChats = await _chatRepository.GetUserChatsAsync(myId, cancellationToken);
|
||||
var personalChat = existingChats.FirstOrDefault(c => c.Type == ChatType.Personal && c.Members.All(m => chatMembers.Contains(m.UserId)) && c.Members.Count == chatMembers.Count);
|
||||
|
||||
if (personalChat != null)
|
||||
{
|
||||
chatId = personalChat.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
var friendId = chatMembers.FirstOrDefault(id => id != myId);
|
||||
if (friendId == Guid.Empty)
|
||||
{
|
||||
friendId = myId;
|
||||
}
|
||||
|
||||
var command = new CreateChatCommand(string.Empty, ChatType.Personal, new List<Guid> { myId, friendId });
|
||||
var res = await _sender.Send(command, cancellationToken);
|
||||
if (res.IsFailure)
|
||||
{
|
||||
return Result.Failure<ExecuteImportResponseDto>(ChatErrors.ImportCreateChatFailed(res.Error.Description ?? res.Error.Code));
|
||||
}
|
||||
|
||||
chatId = res.Value;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var command = new CreateChatCommand(request.GroupName ?? "Импортированный чат", ChatType.Group, chatMembers);
|
||||
var res = await _sender.Send(command, cancellationToken);
|
||||
if (res.IsFailure)
|
||||
{
|
||||
return Result.Failure<ExecuteImportResponseDto>(ChatErrors.ImportCreateChatFailed(res.Error.Description ?? res.Error.Code));
|
||||
}
|
||||
|
||||
chatId = res.Value;
|
||||
}
|
||||
|
||||
int importedCount = 0;
|
||||
|
||||
using (var archive = ZipFile.OpenRead(tempPath))
|
||||
{
|
||||
var htmlEntries = archive.Entries
|
||||
.Where(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase) && e.Name.StartsWith("messages", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(e =>
|
||||
{
|
||||
var name = e.Name.ToLower().Replace("messages", "").Replace(".html", "");
|
||||
return string.IsNullOrEmpty(name) ? 0 : int.TryParse(name, out var num) ? num : 999999;
|
||||
})
|
||||
.ToList();
|
||||
|
||||
Guid lastSenderGuid = myId;
|
||||
DateTime lastCreatedAt = DateTime.UtcNow;
|
||||
Dictionary<string, Guid> messageIdMap = new();
|
||||
Message? lastSavedMessage = null;
|
||||
|
||||
foreach (var entry in htmlEntries)
|
||||
{
|
||||
using var stream = entry.Open();
|
||||
var parser = new HtmlParser();
|
||||
var doc = parser.ParseDocument(stream);
|
||||
|
||||
var messageNodes = doc.QuerySelectorAll(".message");
|
||||
if (messageNodes == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var baseDir = Path.GetDirectoryName(entry.FullName)?.Replace("\\", "/") ?? "";
|
||||
if (!string.IsNullOrEmpty(baseDir) && !baseDir.EndsWith("/"))
|
||||
{
|
||||
baseDir += "/";
|
||||
}
|
||||
|
||||
foreach (var node in messageNodes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fromNameNode = node.QuerySelector(".from_name");
|
||||
var textNode = node.QuerySelector(".text");
|
||||
|
||||
var dateNode = node.QuerySelector(".date[title]")
|
||||
?? node.QuerySelector(".pull_right[title]")
|
||||
?? node.QuerySelector("[title]");
|
||||
|
||||
if (fromNameNode != null)
|
||||
{
|
||||
var nameNodeText = (AngleSharp.Dom.IElement)fromNameNode.Clone();
|
||||
var innerSpans = nameNodeText.QuerySelectorAll("span");
|
||||
foreach (var span in innerSpans)
|
||||
{
|
||||
span.Remove();
|
||||
}
|
||||
|
||||
var name = nameNodeText.TextContent.Trim();
|
||||
if (request.Mapping.TryGetValue(name, out var mappedId) && mappedId != Guid.Empty)
|
||||
{
|
||||
lastSenderGuid = mappedId;
|
||||
}
|
||||
else
|
||||
{
|
||||
lastSenderGuid = myId;
|
||||
}
|
||||
}
|
||||
|
||||
Guid senderGuid = lastSenderGuid;
|
||||
|
||||
string content = "";
|
||||
var mainBodyNode = node.QuerySelector(".body");
|
||||
var isForwarded = node.QuerySelector(".forwarded") != null;
|
||||
|
||||
var contentTextNode = isForwarded
|
||||
? (node.QuerySelector(".body > .text") ?? node.QuerySelector(".text:not(.forwarded .text)"))
|
||||
: node.QuerySelector(".text");
|
||||
|
||||
if (contentTextNode != null)
|
||||
{
|
||||
var html = contentTextNode.InnerHtml
|
||||
.Replace("<br>", "\n", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("<br/>", "\n", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("<br />", "\n", StringComparison.OrdinalIgnoreCase);
|
||||
var tempParser = new HtmlParser();
|
||||
var tempDoc = tempParser.ParseDocument("<div>" + html + "</div>");
|
||||
content = tempDoc.Body?.TextContent.Trim() ?? "";
|
||||
}
|
||||
|
||||
DateTime createdAt = lastCreatedAt;
|
||||
var titleNodes = node.QuerySelectorAll("[title]");
|
||||
bool parsed = false;
|
||||
|
||||
if (titleNodes != null)
|
||||
{
|
||||
foreach (var tnode in titleNodes)
|
||||
{
|
||||
var dateStr = tnode.GetAttribute("title")?.Trim() ?? "";
|
||||
|
||||
if (dateStr.Length >= 10 && char.IsDigit(dateStr[0]) && char.IsDigit(dateStr[1]))
|
||||
{
|
||||
var cleanStr = dateStr.Replace("UTC", "", StringComparison.OrdinalIgnoreCase).Trim();
|
||||
|
||||
if (DateTimeOffset.TryParseExact(cleanStr, "dd.MM.yyyy HH:mm:ss zzz", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dto))
|
||||
{
|
||||
createdAt = dto.UtcDateTime;
|
||||
parsed = true;
|
||||
break;
|
||||
}
|
||||
else if (DateTime.TryParseExact(cleanStr, "dd.MM.yyyy HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out var dt))
|
||||
{
|
||||
createdAt = dt;
|
||||
parsed = true;
|
||||
break;
|
||||
}
|
||||
else if (DateTime.TryParse(cleanStr, out var dFallback))
|
||||
{
|
||||
createdAt = dFallback.ToUniversalTime();
|
||||
parsed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsed)
|
||||
{
|
||||
Console.WriteLine("Warning: Could not parse date in imported message! Using lastCreatedAt.");
|
||||
}
|
||||
else
|
||||
{
|
||||
lastCreatedAt = createdAt;
|
||||
}
|
||||
|
||||
Guid? forwardedFromId = null;
|
||||
var forwardedNode = node.QuerySelector(".forwarded.body");
|
||||
if (forwardedNode != null)
|
||||
{
|
||||
var fwdNameNode = forwardedNode.QuerySelector(".from_name");
|
||||
var fwdNameText = fwdNameNode != null ? (AngleSharp.Dom.IElement)fwdNameNode.Clone() : null;
|
||||
if (fwdNameText != null)
|
||||
{
|
||||
var innerSpans = fwdNameText.QuerySelectorAll("span");
|
||||
foreach (var s in innerSpans)
|
||||
{
|
||||
s.Remove();
|
||||
}
|
||||
}
|
||||
var fwdName = fwdNameText != null ? fwdNameText.TextContent.Trim() : "Неизвестного";
|
||||
|
||||
if (request.Mapping.TryGetValue(fwdName, out var mappedFwdId) && mappedFwdId != Guid.Empty)
|
||||
{
|
||||
forwardedFromId = mappedFwdId;
|
||||
}
|
||||
else if (fwdName == "Это я" || fwdName == request.Mapping.FirstOrDefault(x => x.Value == myId).Key)
|
||||
{
|
||||
forwardedFromId = myId;
|
||||
}
|
||||
|
||||
var fwdTextNode = forwardedNode.QuerySelector(".text");
|
||||
string fwdContent = "";
|
||||
if (fwdTextNode != null)
|
||||
{
|
||||
var fHtml = fwdTextNode.InnerHtml
|
||||
.Replace("<br>", "\n", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("<br/>", "\n", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("<br />", "\n", StringComparison.OrdinalIgnoreCase);
|
||||
var tempParser = new HtmlParser();
|
||||
var tempDoc = tempParser.ParseDocument("<div>" + fHtml + "</div>");
|
||||
fwdContent = tempDoc.Body?.TextContent.Trim() ?? "";
|
||||
}
|
||||
|
||||
if (forwardedFromId == null)
|
||||
{
|
||||
content = string.IsNullOrEmpty(content)
|
||||
? $"[Переслано от {fwdName}]:\n{fwdContent}"
|
||||
: $"{content}\n\n[Переслано от {fwdName}]:\n{fwdContent}";
|
||||
}
|
||||
else if (string.IsNullOrEmpty(content))
|
||||
{
|
||||
content = fwdContent;
|
||||
}
|
||||
}
|
||||
|
||||
Guid? replyToId = null;
|
||||
var replyNode = node.QuerySelector(".reply_to a");
|
||||
if (replyNode != null)
|
||||
{
|
||||
var href = replyNode.GetAttribute("href");
|
||||
if (href != null && href.StartsWith("#go_to_"))
|
||||
{
|
||||
var tgId = href.Substring("#go_to_".Length);
|
||||
if (messageIdMap.TryGetValue(tgId, out var mappedMsgId))
|
||||
{
|
||||
replyToId = mappedMsgId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var mediaNodes = node.QuerySelectorAll("a.photo_wrap, a.animated_wrap, video, audio, a.document, a.media_voice_message, a.media_video, img.sticker").ToList();
|
||||
if (mediaNodes.Count == 0)
|
||||
{
|
||||
var fallback = node.QuerySelectorAll(".media_wrap a[href]");
|
||||
mediaNodes.AddRange(fallback);
|
||||
}
|
||||
|
||||
var messageType = "text";
|
||||
|
||||
(string mType, string cType) GetMediaTypes(string fileUrl)
|
||||
{
|
||||
var ext = Path.GetExtension(fileUrl)?.ToLower();
|
||||
return ext switch
|
||||
{
|
||||
".jpg" or ".jpeg" or ".png" or ".webp" => ("image", "image/jpeg"),
|
||||
".mp4" or ".mov" or ".avi" => ("video", "video/mp4"),
|
||||
".ogg" or ".mp3" => ("voice", "audio/ogg"),
|
||||
_ => ("file", "application/octet-stream")
|
||||
};
|
||||
}
|
||||
|
||||
if (mediaNodes != null && mediaNodes.Count > 0)
|
||||
{
|
||||
var firstHref = mediaNodes[0].GetAttribute("href") ?? mediaNodes[0].GetAttribute("src");
|
||||
if (firstHref != null)
|
||||
{
|
||||
messageType = GetMediaTypes(firstHref).mType;
|
||||
if (mediaNodes[0].ClassName?.Contains("animated") == true || firstHref.EndsWith(".mp4"))
|
||||
{
|
||||
if (mediaNodes[0].ClassName?.Contains("animated") == true)
|
||||
{
|
||||
messageType = "image";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool isJoined = fromNameNode == null;
|
||||
bool hasMedia = mediaNodes != null && mediaNodes.Count > 0;
|
||||
Message? targetMessage = null;
|
||||
|
||||
// Check if we should combine this message with the previous one
|
||||
// We combine if: it's joined AND it's just media/text within 60s AND same sender
|
||||
// Even if it's forwarded, Telegram exports media groups as joined forwarded messages.
|
||||
bool shouldCombine = isJoined && lastSavedMessage is MediaMessage
|
||||
&& Math.Abs((createdAt - lastSavedMessage.CreatedAt).TotalSeconds) <= 60
|
||||
&& lastSavedMessage.SenderId == senderGuid
|
||||
&& lastSavedMessage.ForwardedFromId == forwardedFromId;
|
||||
|
||||
if (shouldCombine)
|
||||
{
|
||||
targetMessage = lastSavedMessage;
|
||||
if (targetMessage is MediaMessage mm && !string.IsNullOrEmpty(content) && content != mm.Content)
|
||||
{
|
||||
// If the joined message has text (caption), append it
|
||||
mm.AppendImportedCaption(content);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
string finalContent = content;
|
||||
if (hasMedia)
|
||||
{
|
||||
targetMessage = new MediaMessage(Guid.NewGuid(), chatId, senderGuid, MediaType.File, finalContent, replyToId, forwardedFromId, createdAt, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
targetMessage = new TextMessage(Guid.NewGuid(), chatId, senderGuid, finalContent, replyToId, null, forwardedFromId, createdAt, true);
|
||||
}
|
||||
|
||||
var idAttr = node.GetAttribute("id");
|
||||
if (!string.IsNullOrEmpty(idAttr))
|
||||
{
|
||||
messageIdMap[idAttr] = targetMessage.Id;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasMedia)
|
||||
{
|
||||
var seenMedia = new HashSet<string>();
|
||||
var validMediaExtracted = new List<(string href, string finalMType, string cType)>();
|
||||
|
||||
foreach (var mediaNode in mediaNodes!)
|
||||
{
|
||||
string? href = mediaNode.GetAttribute("href") ?? mediaNode.GetAttribute("src");
|
||||
if (!string.IsNullOrEmpty(href) && !href.StartsWith("http"))
|
||||
{
|
||||
if (!seenMedia.Add(href)) continue;
|
||||
|
||||
var types = GetMediaTypes(href);
|
||||
var finalMType = types.mType;
|
||||
if (mediaNode.ClassName?.Contains("animated") == true)
|
||||
{
|
||||
finalMType = "image";
|
||||
}
|
||||
|
||||
validMediaExtracted.Add((href, finalMType, types.cType));
|
||||
}
|
||||
}
|
||||
|
||||
// В Telegram экспорте если в одном .message блоке есть и видео, и картинка - картинка это просто миниатюра (thumbnail).
|
||||
// Реальные альбомы идут отдельными .message div'ами c классом joined.
|
||||
// Поэтому мы просто удаляем картинку, чтобы она не дублировалась как отдельный файл в галерее!
|
||||
if (validMediaExtracted.Any(m => m.finalMType == "video") && validMediaExtracted.Any(m => m.finalMType == "image"))
|
||||
{
|
||||
validMediaExtracted.RemoveAll(m => m.finalMType == "image");
|
||||
}
|
||||
|
||||
foreach (var mediaTuple in validMediaExtracted)
|
||||
{
|
||||
var zipPath = baseDir + mediaTuple.href.Replace("\\", "/");
|
||||
var zipEntry = archive.GetEntry(zipPath);
|
||||
if (zipEntry != null)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
using var zipfs = zipEntry.Open();
|
||||
await zipfs.CopyToAsync(ms, cancellationToken);
|
||||
ms.Position = 0;
|
||||
|
||||
var parsedType = Enum.TryParse<MediaType>(mediaTuple.finalMType, true, out var mTypeEnum) ? mTypeEnum : MediaType.File;
|
||||
if (targetMessage is MediaMessage mm)
|
||||
{
|
||||
var fileId = await _fileStorage.UploadFileAsync(ms, Path.GetFileName(mediaTuple.href), mediaTuple.cType);
|
||||
mm.AddMedia(parsedType, $"/api/files/{fileId}", Path.GetFileName(mediaTuple.href), zipEntry.Length);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var reactionNodes = node.QuerySelectorAll(".reactions .reaction");
|
||||
foreach (var reactionNode in reactionNodes)
|
||||
{
|
||||
var emojiNode = reactionNode.QuerySelector(".emoji");
|
||||
if (emojiNode == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var emoji = emojiNode.TextContent.Trim();
|
||||
|
||||
var userpicNodes = reactionNode.QuerySelectorAll(".userpics .userpic .initials[title]");
|
||||
foreach (var userpicNode in userpicNodes)
|
||||
{
|
||||
var title = userpicNode.GetAttribute("title")?.Trim();
|
||||
if (!string.IsNullOrEmpty(title) && request.Mapping.TryGetValue(title, out var rUserId) && rUserId != Guid.Empty && targetMessage != null)
|
||||
{
|
||||
var reaction = new MessageReaction(targetMessage.Id, rUserId, emoji);
|
||||
await _reactionRepository.AddAsync(reaction, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (targetMessage != null && targetMessage != lastSavedMessage && (!string.IsNullOrEmpty(content) || targetMessage.Media.Any()))
|
||||
{
|
||||
_messageRepository.Add(targetMessage);
|
||||
lastSavedMessage = targetMessage;
|
||||
importedCount++;
|
||||
}
|
||||
else if (shouldCombine && lastSavedMessage != null)
|
||||
{
|
||||
await _messageRepository.UpdateAsync(lastSavedMessage, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch { /* ignore single message parse error */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await _uow.SaveChangesAsync(cancellationToken);
|
||||
|
||||
try { System.IO.File.Delete(tempPath); TelegramImportState.TempZips.TryRemove(request.Token, out _); } catch { }
|
||||
|
||||
await _hubContext.Clients.Users(chatMembers.Select(x => x.ToString())).SendAsync("history_updated", new { chatId });
|
||||
|
||||
return Result.Success(new ExecuteImportResponseDto(true, importedCount, chatId));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
|
||||
<ProjectReference Include="..\Conversations\Knot.Modules.Conversations.csproj" />
|
||||
<ProjectReference Include="..\Messaging\Knot.Modules.Messaging.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,40 @@
|
||||
using Carter;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.TelegramImport.Application.TelegramImport.DTOs;
|
||||
using Knot.Modules.TelegramImport.Application.TelegramImport;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Knot.Modules.TelegramImport.Presentation.Endpoints;
|
||||
|
||||
public sealed class TelegramImportEndpoints : ICarterModule
|
||||
{
|
||||
public void AddRoutes(IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("api/import/telegram").RequireAuthorization();
|
||||
|
||||
group.MapPost("analyze", async (HttpRequest req, ISender sender, CancellationToken ct) =>
|
||||
{
|
||||
if (!req.HasFormContentType) return Results.BadRequest("No file uploaded.");
|
||||
var form = await req.ReadFormAsync(ct);
|
||||
var file = form.Files.FirstOrDefault();
|
||||
if (file == null) return Results.BadRequest("No file uploaded.");
|
||||
|
||||
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();
|
||||
|
||||
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 result = await sender.Send(command, ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description ?? result.Error.Code);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user