Импорт из телеграм

This commit is contained in:
Халимов Рустам
2026-03-16 17:51:49 +03:00
parent 33007b3c72
commit 5600067f8d
20 changed files with 1362 additions and 144 deletions

View File

@@ -0,0 +1,282 @@
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 HtmlAgilityPack;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Identity.Domain;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Chats.Infrastructure.SignalR;
using Knot.Modules.Chats.Application.Chats.Create;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/import/telegram")]
public sealed class TelegramImportController : ControllerBase
{
private readonly ISender _sender;
private readonly IUserContext _userContext;
private readonly IChatsUnitOfWork _unitOfWork;
private readonly IChatRepository _chatRepository;
private readonly IMessageRepository _messageRepository;
private readonly IFileStorageService _fileStorage;
private readonly IHubContext<ChatHub> _hubContext;
// In-memory store for uploaded zips (good enough for typical cases, ideally should be removed after use or by timer)
private static readonly ConcurrentDictionary<Guid, string> _tempZips = new();
public TelegramImportController(
ISender sender,
IUserContext userContext,
IChatsUnitOfWork unitOfWork,
IChatRepository chatRepository,
IMessageRepository messageRepository,
IFileStorageService fileStorage,
IHubContext<ChatHub> hubContext)
{
_sender = sender;
_userContext = userContext;
_unitOfWork = unitOfWork;
_chatRepository = chatRepository;
_messageRepository = messageRepository;
_fileStorage = fileStorage;
_hubContext = hubContext;
}
[HttpPost("analyze")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 10L * 1024 * 1024 * 1024)] // 10GB for big exports
public async Task<IActionResult> Analyze(IFormFile file, CancellationToken ct)
{
if (file == null || file.Length == 0) return BadRequest("No file uploaded");
if (!file.FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) return BadRequest("Must be a ZIP archive");
var token = Guid.NewGuid();
var tempPath = Path.Combine(Path.GetTempPath(), $"{token}.zip");
await using (var fs = new FileStream(tempPath, FileMode.Create))
{
await file.CopyToAsync(fs, ct);
}
var names = new HashSet<string>();
// Open zip and quickly scan messages.html
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 doc = new HtmlDocument();
doc.Load(stream);
var messageNodes = doc.DocumentNode.SelectNodes("//div[contains(@class, 'message ')]");
if (messageNodes == null) continue;
foreach (var node in messageNodes)
{
var fromNameNode = node.SelectSingleNode(".//div[contains(@class, 'from_name')]");
if (fromNameNode != null)
{
var name = fromNameNode.InnerText.Trim();
// Ignore standard system names if obvious (for now everything is recorded)
if (!string.IsNullOrWhiteSpace(name))
{
names.Add(name);
}
}
}
}
}
_tempZips[token] = tempPath;
return Ok(new
{
token,
names = names.ToList()
});
}
public sealed record ExecuteImportRequest(Guid Token, Dictionary<string, Guid> Mapping);
[HttpPost("execute")]
public async Task<IActionResult> Execute([FromBody] ExecuteImportRequest req, CancellationToken ct)
{
if (!_tempZips.TryGetValue(req.Token, out var tempPath))
return BadRequest("Session not found or expired");
if (!System.IO.File.Exists(tempPath))
return BadRequest("ZIP file lost");
var myId = _userContext.UserId;
// Collect targeted users to check whose chat it is. Find the friend.
// Usually, the mapping contains MyId and FriendId.
var targetUserIds = req.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)
{
// Find existing personal chat
var existingChats = await _chatRepository.GetUserChatsAsync(myId, ct);
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
{
// Create personal chat
var friendId = chatMembers.FirstOrDefault(id => id != myId);
if (friendId == Guid.Empty) friendId = myId; // Notes to self
var command = new CreateChatCommand(string.Empty, ChatType.Personal, new List<Guid> { myId, friendId });
var res = await _sender.Send(command, ct);
if (res.IsFailure) return BadRequest(res.Error);
chatId = res.Value;
}
}
else
{
// Create a group
var command = new CreateChatCommand("Импортированный чат", ChatType.Group, chatMembers);
var res = await _sender.Send(command, ct);
if (res.IsFailure) return BadRequest(res.Error);
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 => e.FullName); // Read chronologically
foreach (var entry in htmlEntries)
{
using var stream = entry.Open();
var doc = new HtmlDocument();
doc.Load(stream);
var messageNodes = doc.DocumentNode.SelectNodes("//div[contains(@class, '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.SelectSingleNode(".//div[contains(@class, 'from_name')]");
var textNode = node.SelectSingleNode(".//div[contains(@class, 'text')]");
var dateNode = node.SelectSingleNode(".//div[contains(@class, 'pull_right date')]");
// Default values
var senderGuid = myId; // Fallback
if (fromNameNode != null)
{
var name = fromNameNode.InnerText.Trim();
if (req.Mapping.TryGetValue(name, out var mappedId) && mappedId != Guid.Empty)
senderGuid = mappedId;
}
var content = textNode?.InnerText?.Trim() ?? "";
DateTime createdAt = DateTime.UtcNow;
if (dateNode != null && dateNode.Attributes["title"] != null)
{
var dateStr = dateNode.Attributes["title"].Value;
if (DateTime.TryParse(dateStr, out var d))
createdAt = d.ToUniversalTime();
}
var mediaNodes = node.SelectNodes(".//a[contains(@class, 'photo_wrap')] | .//video | .//a[contains(@class, 'document')] | .//a[contains(@class, 'media_voice_message')]");
var messageType = "text";
if (mediaNodes != null && mediaNodes.Count > 0)
{
var firstHref = mediaNodes[0].Attributes["href"]?.Value ?? mediaNodes[0].Attributes["src"]?.Value;
if (firstHref != null)
{
if (firstHref.EndsWith(".jpg") || firstHref.EndsWith(".png")) messageType = "image";
else if (firstHref.EndsWith(".mp4")) messageType = "video";
else if (firstHref.EndsWith(".ogg")) messageType = "voice";
else messageType = "file";
}
}
var newMessage = Message.Import(chatId, senderGuid, content, messageType, createdAt);
if (mediaNodes != null)
{
foreach (var mediaNode in mediaNodes)
{
string? href = mediaNode.Attributes["href"]?.Value ?? mediaNode.Attributes["src"]?.Value;
if (!string.IsNullOrEmpty(href) && !href.StartsWith("http"))
{
// Local file in zip
var zipPath = baseDir + href.Replace("\\", "/");
var zipEntry = archive.GetEntry(zipPath);
if (zipEntry != null)
{
using var ms = new MemoryStream();
using var zipfs = zipEntry.Open();
await zipfs.CopyToAsync(ms, ct);
ms.Position = 0;
string cType = "application/octet-stream";
var mType = "file";
if (href.EndsWith(".jpg") || href.EndsWith(".png")) { cType = "image/jpeg"; mType = "image"; messageType = "image"; }
else if (href.EndsWith(".mp4")) { cType = "video/mp4"; mType = "video"; messageType = "video"; }
else if (href.EndsWith(".ogg")) { cType = "audio/ogg"; mType = "voice"; messageType = "voice"; }
var fileId = await _fileStorage.UploadFileAsync(ms, Path.GetFileName(href), cType);
newMessage.AddMedia(mType, $"/api/files/{fileId}", Path.GetFileName(href), zipEntry.Length);
}
}
}
}
// Save text message without media if content is just empty, but wait, if it's empty and has no media, it was probably a system message (like pinned, joined).
if (!string.IsNullOrEmpty(content) || newMessage.Media.Any())
{
_messageRepository.Add(newMessage);
importedCount++;
}
}
catch { /* ignore single message parse error */ }
}
}
}
await _unitOfWork.SaveChangesAsync(ct);
// Delete temp zip
try { System.IO.File.Delete(tempPath); _tempZips.TryRemove(req.Token, out _); } catch { }
// Notify UI for all members of the chat
await _hubContext.Clients.Users(chatMembers.Select(x => x.ToString())).SendAsync("history_updated", new { chatId });
return Ok(new { success = true, messagesImported = importedCount, chatId });
}
}