Импорт чатов с телеграмм, асинхронная загрузка сообщений
This commit is contained in:
@@ -9,6 +9,7 @@ using Knot.Modules.Identity.Application.Abstractions;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Knot.Shared.Kernel.Configuration;
|
using Knot.Shared.Kernel.Configuration;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
namespace Host.Controllers;
|
namespace Host.Controllers;
|
||||||
|
|
||||||
@@ -29,41 +30,56 @@ public sealed class MessagesController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("chat/{chatId:guid}")]
|
[HttpGet("chat/{chatId:guid}")]
|
||||||
public async Task<IActionResult> GetMessages(Guid chatId, [FromServices] IMessageRepository messageRepository, [FromQuery] string? cursor, CancellationToken ct)
|
public async Task<IActionResult> GetMessages(Guid chatId, [FromServices] IMessageRepository messageRepository, [FromQuery] string? cursor, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var messages = await messageRepository.GetChatMessagesAsync(chatId, 50, 0, ct);
|
DateTime? cursorDate = null;
|
||||||
|
if (!string.IsNullOrEmpty(cursor) && DateTime.TryParse(cursor, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed))
|
||||||
// Filter out messages deleted for this user
|
|
||||||
messages = messages.Where(m => !m.DeletedByUsers.Contains(_userContext.UserId)).ToList();
|
|
||||||
|
|
||||||
// Collect all user IDs needed (senders, forwarded from)
|
|
||||||
var userIds = messages.Select(m => m.SenderId).ToList();
|
|
||||||
userIds.AddRange(messages.Where(m => m.ForwardedFromId.HasValue).Select(m => m.ForwardedFromId!.Value));
|
|
||||||
|
|
||||||
var senders = new Dictionary<Guid, Knot.Modules.Identity.Domain.User>();
|
|
||||||
foreach (var id in userIds.Distinct())
|
|
||||||
{
|
{
|
||||||
var user = await _userRepository.GetByIdAsync(id, ct);
|
cursorDate = parsed.ToUniversalTime();
|
||||||
if (user != null) senders[id] = user;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var messages = await messageRepository.GetChatMessagesCursorAsync(chatId, cursorDate, 100, ct);
|
||||||
|
var senders = new Dictionary<Guid, Knot.Modules.Identity.Domain.User>();
|
||||||
var result = new List<object>();
|
var result = new List<object>();
|
||||||
|
|
||||||
foreach (var m in messages)
|
foreach (var m in messages)
|
||||||
{
|
{
|
||||||
|
if (m.DeletedByUsers.Contains(_userContext.UserId)) continue;
|
||||||
|
|
||||||
|
if (!senders.ContainsKey(m.SenderId))
|
||||||
|
{
|
||||||
|
var user = await _userRepository.GetByIdAsync(m.SenderId, ct);
|
||||||
|
if (user != null) senders[m.SenderId] = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m.ForwardedFromId.HasValue && !senders.ContainsKey(m.ForwardedFromId.Value))
|
||||||
|
{
|
||||||
|
var fuser = await _userRepository.GetByIdAsync(m.ForwardedFromId.Value, ct);
|
||||||
|
if (fuser != null) senders[m.ForwardedFromId.Value] = fuser;
|
||||||
|
}
|
||||||
|
|
||||||
object? replyToObj = null;
|
object? replyToObj = null;
|
||||||
if (m.ReplyToId.HasValue)
|
if (m.ReplyToId.HasValue)
|
||||||
{
|
{
|
||||||
var replyMsg = await messageRepository.GetByIdAsync(m.ReplyToId.Value, ct);
|
var replyMsg = await messageRepository.GetByIdAsync(m.ReplyToId.Value, ct);
|
||||||
if (replyMsg != null)
|
if (replyMsg != null)
|
||||||
{
|
{
|
||||||
var replySender = await _userRepository.GetByIdAsync(replyMsg.SenderId, ct);
|
if (!senders.ContainsKey(replyMsg.SenderId))
|
||||||
|
{
|
||||||
|
var replySender = await _userRepository.GetByIdAsync(replyMsg.SenderId, ct);
|
||||||
|
if (replySender != null) senders[replyMsg.SenderId] = replySender;
|
||||||
|
}
|
||||||
|
var senderObj = senders.TryGetValue(replyMsg.SenderId, out var rs)
|
||||||
|
? new { id = rs.Id, username = rs.Username, displayName = rs.DisplayName, avatar = rs.Avatar }
|
||||||
|
: null;
|
||||||
|
|
||||||
replyToObj = new
|
replyToObj = new
|
||||||
{
|
{
|
||||||
id = replyMsg.Id,
|
id = replyMsg.Id,
|
||||||
content = replyMsg.Content,
|
content = replyMsg.Content,
|
||||||
isDeleted = replyMsg.IsDeleted,
|
isDeleted = replyMsg.IsDeleted,
|
||||||
media = replyMsg.Media.Select(rm => new { rm.Id, rm.Type, rm.Url }).ToList(),
|
media = replyMsg.Media.Select(rm => new { rm.Id, rm.Type, rm.Url }).ToList(),
|
||||||
sender = replySender != null ? new { id = replySender.Id, username = replySender.Username, displayName = replySender.DisplayName } : null
|
sender = senderObj
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,15 +87,21 @@ public sealed class MessagesController : ControllerBase
|
|||||||
var reactionsWithUser = new List<object>();
|
var reactionsWithUser = new List<object>();
|
||||||
foreach (var r in m.Reactions)
|
foreach (var r in m.Reactions)
|
||||||
{
|
{
|
||||||
var rUser = await _userRepository.GetByIdAsync(r.UserId, ct);
|
if (!senders.ContainsKey(r.UserId))
|
||||||
|
{
|
||||||
|
var rUser = await _userRepository.GetByIdAsync(r.UserId, ct);
|
||||||
|
if (rUser != null) senders[r.UserId] = rUser;
|
||||||
|
}
|
||||||
|
var userObj = senders.TryGetValue(r.UserId, out var ru)
|
||||||
|
? new { id = ru.Id, username = ru.Username, displayName = ru.DisplayName }
|
||||||
|
: new { id = r.UserId, username = "unknown", displayName = "Unknown" };
|
||||||
|
|
||||||
reactionsWithUser.Add(new
|
reactionsWithUser.Add(new
|
||||||
{
|
{
|
||||||
id = r.Id,
|
id = r.Id,
|
||||||
emoji = r.Emoji,
|
emoji = r.Emoji,
|
||||||
userId = r.UserId,
|
userId = r.UserId,
|
||||||
user = rUser != null
|
user = userObj
|
||||||
? new { id = rUser.Id, username = rUser.Username, displayName = rUser.DisplayName }
|
|
||||||
: new { id = r.UserId, username = "unknown", displayName = "Unknown" }
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,9 +140,9 @@ public sealed class MessagesController : ControllerBase
|
|||||||
username = s.Username,
|
username = s.Username,
|
||||||
displayName = s.DisplayName,
|
displayName = s.DisplayName,
|
||||||
avatar = s.Avatar
|
avatar = s.Avatar
|
||||||
} : new { id = m.SenderId, username = "unknown", displayName = "Unknown", avatar = (string?)null },
|
} : null,
|
||||||
reactions = reactionsWithUser,
|
readBy = m.ReadBy.Select(r => r.UserId).ToList(),
|
||||||
readBy = m.ReadBy.Select(r => new { userId = r.UserId }).ToList()
|
reactions = reactionsWithUser
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,24 +77,28 @@ public sealed class TelegramImportController : ControllerBase
|
|||||||
// Open zip and quickly scan messages.html
|
// Open zip and quickly scan messages.html
|
||||||
using (var archive = ZipFile.OpenRead(tempPath))
|
using (var archive = ZipFile.OpenRead(tempPath))
|
||||||
{
|
{
|
||||||
var htmlEntries = archive.Entries.Where(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase) && e.Name.StartsWith("messages", StringComparison.OrdinalIgnoreCase));
|
var htmlEntries = archive.Entries
|
||||||
|
.Where(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase) && e.Name.StartsWith("messages", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
foreach (var entry in htmlEntries)
|
foreach (var entry in htmlEntries)
|
||||||
{
|
{
|
||||||
using var stream = entry.Open();
|
using var stream = entry.Open();
|
||||||
var doc = new HtmlDocument();
|
var parser = new AngleSharp.Html.Parser.HtmlParser();
|
||||||
doc.Load(stream);
|
var doc = parser.ParseDocument(stream);
|
||||||
|
|
||||||
var messageNodes = doc.DocumentNode.SelectNodes("//div[contains(@class, 'message ')]");
|
var messageNodes = doc.QuerySelectorAll(".message");
|
||||||
if (messageNodes == null) continue;
|
if (messageNodes == null) continue;
|
||||||
|
|
||||||
foreach (var node in messageNodes)
|
foreach (var node in messageNodes)
|
||||||
{
|
{
|
||||||
var fromNameNode = node.SelectSingleNode(".//div[contains(@class, 'from_name')]");
|
var fromNameNode = node.QuerySelector(".from_name");
|
||||||
if (fromNameNode != null)
|
if (fromNameNode != null)
|
||||||
{
|
{
|
||||||
var name = fromNameNode.InnerText.Trim();
|
var nameNodeText = (AngleSharp.Dom.IElement)fromNameNode.Clone();
|
||||||
// Ignore standard system names if obvious (for now everything is recorded)
|
var innerSpans = nameNodeText.QuerySelectorAll("span");
|
||||||
|
foreach (var span in innerSpans) span.Remove();
|
||||||
|
|
||||||
|
var name = nameNodeText.TextContent.Trim();
|
||||||
if (!string.IsNullOrWhiteSpace(name))
|
if (!string.IsNullOrWhiteSpace(name))
|
||||||
{
|
{
|
||||||
names.Add(name);
|
names.Add(name);
|
||||||
@@ -169,15 +173,25 @@ public sealed class TelegramImportController : ControllerBase
|
|||||||
{
|
{
|
||||||
var htmlEntries = archive.Entries
|
var htmlEntries = archive.Entries
|
||||||
.Where(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase) && e.Name.StartsWith("messages", StringComparison.OrdinalIgnoreCase))
|
.Where(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase) && e.Name.StartsWith("messages", StringComparison.OrdinalIgnoreCase))
|
||||||
.OrderBy(e => e.FullName); // Read chronologically
|
.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)
|
foreach (var entry in htmlEntries)
|
||||||
{
|
{
|
||||||
using var stream = entry.Open();
|
using var stream = entry.Open();
|
||||||
var doc = new HtmlDocument();
|
var parser = new AngleSharp.Html.Parser.HtmlParser();
|
||||||
doc.Load(stream);
|
var doc = parser.ParseDocument(stream);
|
||||||
|
|
||||||
var messageNodes = doc.DocumentNode.SelectNodes("//div[contains(@class, 'message ')]");
|
var messageNodes = doc.QuerySelectorAll(".message");
|
||||||
if (messageNodes == null) continue;
|
if (messageNodes == null) continue;
|
||||||
|
|
||||||
var baseDir = Path.GetDirectoryName(entry.FullName)?.Replace("\\", "/") ?? "";
|
var baseDir = Path.GetDirectoryName(entry.FullName)?.Replace("\\", "/") ?? "";
|
||||||
@@ -187,54 +201,191 @@ public sealed class TelegramImportController : ControllerBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var fromNameNode = node.SelectSingleNode(".//div[contains(@class, 'from_name')]");
|
var fromNameNode = node.QuerySelector(".from_name");
|
||||||
var textNode = node.SelectSingleNode(".//div[contains(@class, 'text')]");
|
var textNode = node.QuerySelector(".text");
|
||||||
var dateNode = node.SelectSingleNode(".//div[contains(@class, 'pull_right date')]");
|
|
||||||
|
var dateNode = node.QuerySelector(".date[title]")
|
||||||
|
?? node.QuerySelector(".pull_right[title]")
|
||||||
|
?? node.QuerySelector("[title]");
|
||||||
|
|
||||||
// Default values
|
|
||||||
var senderGuid = myId; // Fallback
|
|
||||||
if (fromNameNode != null)
|
if (fromNameNode != null)
|
||||||
{
|
{
|
||||||
var name = fromNameNode.InnerText.Trim();
|
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 (req.Mapping.TryGetValue(name, out var mappedId) && mappedId != Guid.Empty)
|
if (req.Mapping.TryGetValue(name, out var mappedId) && mappedId != Guid.Empty)
|
||||||
senderGuid = mappedId;
|
lastSenderGuid = mappedId;
|
||||||
|
else
|
||||||
|
lastSenderGuid = myId;
|
||||||
}
|
}
|
||||||
|
|
||||||
var content = textNode?.InnerText?.Trim() ?? "";
|
Guid senderGuid = lastSenderGuid;
|
||||||
|
|
||||||
DateTime createdAt = DateTime.UtcNow;
|
string content = "";
|
||||||
if (dateNode != null && dateNode.Attributes["title"] != null)
|
if (textNode != null)
|
||||||
{
|
{
|
||||||
var dateStr = dateNode.Attributes["title"].Value;
|
var html = textNode.InnerHtml
|
||||||
if (DateTime.TryParse(dateStr, out var d))
|
.Replace("<br>", "\n", StringComparison.OrdinalIgnoreCase)
|
||||||
createdAt = d.ToUniversalTime();
|
.Replace("<br/>", "\n", StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Replace("<br />", "\n", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var tempParser = new AngleSharp.Html.Parser.HtmlParser();
|
||||||
|
var tempDoc = tempParser.ParseDocument("<div>" + html + "</div>");
|
||||||
|
content = tempDoc.Body.TextContent.Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
var mediaNodes = node.SelectNodes(".//a[contains(@class, 'photo_wrap')] | .//video | .//a[contains(@class, 'document')] | .//a[contains(@class, 'media_voice_message')]");
|
DateTime createdAt = lastCreatedAt;
|
||||||
|
var titleNodes = node.QuerySelectorAll("[title]");
|
||||||
|
bool parsed = false;
|
||||||
|
|
||||||
var messageType = "text";
|
if (titleNodes != null)
|
||||||
if (mediaNodes != null && mediaNodes.Count > 0)
|
|
||||||
{
|
{
|
||||||
var firstHref = mediaNodes[0].Attributes["href"]?.Value ?? mediaNodes[0].Attributes["src"]?.Value;
|
foreach (var tnode in titleNodes)
|
||||||
if (firstHref != null)
|
|
||||||
{
|
{
|
||||||
if (firstHref.EndsWith(".jpg") || firstHref.EndsWith(".png")) messageType = "image";
|
var dateStr = tnode.GetAttribute("title")?.Trim() ?? "";
|
||||||
else if (firstHref.EndsWith(".mp4")) messageType = "video";
|
|
||||||
else if (firstHref.EndsWith(".ogg")) messageType = "voice";
|
if (dateStr.Length >= 10 && char.IsDigit(dateStr[0]) && char.IsDigit(dateStr[1]))
|
||||||
else messageType = "file";
|
{
|
||||||
|
// Remove "UTC" so we can parse timezone offset generically, e.g. "17.10.2025 12:56:25 +03:00"
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var newMessage = Message.Import(chatId, senderGuid, content, messageType, createdAt);
|
if (!parsed)
|
||||||
|
{
|
||||||
|
Console.WriteLine("Warning: Could not parse date in imported message! Using lastCreatedAt.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
lastCreatedAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse forwards
|
||||||
|
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() : "Неизвестного";
|
||||||
|
|
||||||
|
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 AngleSharp.Html.Parser.HtmlParser();
|
||||||
|
var tempDoc = tempParser.ParseDocument("<div>" + fHtml + "</div>");
|
||||||
|
fwdContent = tempDoc.Body.TextContent.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
content = string.IsNullOrEmpty(content)
|
||||||
|
? $"[Переслано от {fwdName}]:\n{fwdContent}"
|
||||||
|
: $"{content}\n\n[Переслано от {fwdName}]:\n{fwdContent}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse replies
|
||||||
|
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";
|
||||||
|
|
||||||
|
// Helper for categorizing files
|
||||||
|
(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isJoined = fromNameNode == null;
|
||||||
|
bool isMediaOnly = string.IsNullOrEmpty(content) && forwardedNode == null && replyToId == null;
|
||||||
|
Message? targetMessage = null;
|
||||||
|
|
||||||
|
if (isJoined && isMediaOnly && lastSavedMessage != null && Math.Abs((createdAt - lastSavedMessage.CreatedAt).TotalSeconds) <= 60 && lastSavedMessage.SenderId == senderGuid)
|
||||||
|
{
|
||||||
|
targetMessage = lastSavedMessage;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
string finalContent = content;
|
||||||
|
targetMessage = Message.Import(chatId, senderGuid, finalContent, messageType, createdAt, replyToId, null);
|
||||||
|
|
||||||
|
var idAttr = node.GetAttribute("id");
|
||||||
|
if (!string.IsNullOrEmpty(idAttr))
|
||||||
|
{
|
||||||
|
messageIdMap[idAttr] = targetMessage.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (mediaNodes != null)
|
if (mediaNodes != null)
|
||||||
{
|
{
|
||||||
foreach (var mediaNode in mediaNodes)
|
foreach (var mediaNode in mediaNodes)
|
||||||
{
|
{
|
||||||
string? href = mediaNode.Attributes["href"]?.Value ?? mediaNode.Attributes["src"]?.Value;
|
string? href = mediaNode.GetAttribute("href") ?? mediaNode.GetAttribute("src");
|
||||||
if (!string.IsNullOrEmpty(href) && !href.StartsWith("http"))
|
if (!string.IsNullOrEmpty(href) && !href.StartsWith("http"))
|
||||||
{
|
{
|
||||||
// Local file in zip
|
|
||||||
var zipPath = baseDir + href.Replace("\\", "/");
|
var zipPath = baseDir + href.Replace("\\", "/");
|
||||||
var zipEntry = archive.GetEntry(zipPath);
|
var zipEntry = archive.GetEntry(zipPath);
|
||||||
if (zipEntry != null)
|
if (zipEntry != null)
|
||||||
@@ -244,23 +395,36 @@ public sealed class TelegramImportController : ControllerBase
|
|||||||
await zipfs.CopyToAsync(ms, ct);
|
await zipfs.CopyToAsync(ms, ct);
|
||||||
ms.Position = 0;
|
ms.Position = 0;
|
||||||
|
|
||||||
string cType = "application/octet-stream";
|
var types = GetMediaTypes(href);
|
||||||
var mType = "file";
|
var fileId = await _fileStorage.UploadFileAsync(ms, Path.GetFileName(href), types.cType);
|
||||||
if (href.EndsWith(".jpg") || href.EndsWith(".png")) { cType = "image/jpeg"; mType = "image"; messageType = "image"; }
|
targetMessage.AddMedia(types.mType, $"/api/files/{fileId}", Path.GetFileName(href), zipEntry.Length);
|
||||||
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).
|
// Parse reactions
|
||||||
if (!string.IsNullOrEmpty(content) || newMessage.Media.Any())
|
var reactionNodes = node.QuerySelectorAll(".reactions .reaction");
|
||||||
|
foreach (var reactionNode in reactionNodes)
|
||||||
{
|
{
|
||||||
_messageRepository.Add(newMessage);
|
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) && req.Mapping.TryGetValue(title, out var rUserId) && rUserId != Guid.Empty)
|
||||||
|
{
|
||||||
|
targetMessage.AddReaction(rUserId, emoji);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetMessage != lastSavedMessage && (!string.IsNullOrEmpty(content) || targetMessage.Media.Any()))
|
||||||
|
{
|
||||||
|
_messageRepository.Add(targetMessage);
|
||||||
|
lastSavedMessage = targetMessage;
|
||||||
importedCount++;
|
importedCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="AngleSharp" Version="1.4.0" />
|
||||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||||
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
|
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
|
||||||
<PackageReference Include="Mapster" Version="7.4.0" />
|
<PackageReference Include="Mapster" Version="7.4.0" />
|
||||||
|
|||||||
@@ -11,5 +11,6 @@ public interface IMessageRepository
|
|||||||
Task AddReadReceiptsAsync(Guid userId, List<Guid> messageIds, CancellationToken cancellationToken);
|
Task AddReadReceiptsAsync(Guid userId, List<Guid> messageIds, CancellationToken cancellationToken);
|
||||||
Task<bool> AddReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken);
|
Task<bool> AddReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken);
|
||||||
Task<bool> RemoveReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken);
|
Task<bool> RemoveReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken);
|
||||||
|
Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, int limit, CancellationToken cancellationToken);
|
||||||
Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
|
Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,9 +64,9 @@ public sealed class Message : AggregateRoot<Guid>
|
|||||||
return new Message(Guid.NewGuid(), chatId, senderId, content, type, replyToId, quote, forwardedFromId, storyId, storyMediaUrl, storyMediaType, DateTime.UtcNow, false);
|
return new Message(Guid.NewGuid(), chatId, senderId, content, type, replyToId, quote, forwardedFromId, storyId, storyMediaUrl, storyMediaType, DateTime.UtcNow, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Message Import(Guid chatId, Guid senderId, string? content, string type, DateTime createdAt, Guid? replyToId = null)
|
public static Message Import(Guid chatId, Guid senderId, string? content, string type, DateTime createdAt, Guid? replyToId = null, Guid? forwardedFromId = null)
|
||||||
{
|
{
|
||||||
return new Message(Guid.NewGuid(), chatId, senderId, content, type, replyToId, null, null, null, null, null, createdAt, true);
|
return new Message(Guid.NewGuid(), chatId, senderId, content, type, replyToId, null, forwardedFromId, null, null, null, createdAt, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void AddMedia(string type, string url, string? filename, long? size)
|
public void AddMedia(string type, string url, string? filename, long? size)
|
||||||
|
|||||||
@@ -39,6 +39,25 @@ public sealed class MessageRepository : IMessageRepository
|
|||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, int limit, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = _dbContext.Messages
|
||||||
|
.Where(m => m.ChatId == chatId);
|
||||||
|
|
||||||
|
if (cursor.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(m => m.CreatedAt < cursor.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query
|
||||||
|
.OrderByDescending(m => m.CreatedAt)
|
||||||
|
.Take(limit)
|
||||||
|
.Include(m => m.Media)
|
||||||
|
.Include(m => m.ReadBy)
|
||||||
|
.Include(m => m.Reactions)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<Message>> SearchMessagesAsync(string query, Guid? chatId, CancellationToken cancellationToken)
|
public async Task<List<Message>> SearchMessagesAsync(string query, Guid? chatId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var q = _dbContext.Messages.AsQueryable();
|
var q = _dbContext.Messages.AsQueryable();
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
typingUsers,
|
typingUsers,
|
||||||
pinnedMessages,
|
pinnedMessages,
|
||||||
isLoadingMessages,
|
isLoadingMessages,
|
||||||
|
hasMoreMessages,
|
||||||
|
loadMessages,
|
||||||
setActiveChat,
|
setActiveChat,
|
||||||
} = useChatStore();
|
} = useChatStore();
|
||||||
|
|
||||||
@@ -113,6 +115,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
}
|
}
|
||||||
const firstUnreadId = activeChat === sessionUnreadRef.current.chatId ? sessionUnreadRef.current.msgId : null;
|
const firstUnreadId = activeChat === sessionUnreadRef.current.chatId ? sessionUnreadRef.current.msgId : null;
|
||||||
const lastObservedMessageIdRef = useRef<string | null>(null);
|
const lastObservedMessageIdRef = useRef<string | null>(null);
|
||||||
|
const prevScrollHeightRef = useRef<number>(0);
|
||||||
|
|
||||||
// Load muted state
|
// Load muted state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -199,7 +202,16 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
}
|
}
|
||||||
setScrollReady(true);
|
setScrollReady(true);
|
||||||
}
|
}
|
||||||
}, [activeChat, isLoadingMessages]);
|
}, [activeChat, isLoadingMessages && chatMessages.length === 0]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (prevScrollHeightRef.current > 0 && messagesContainerRef.current) {
|
||||||
|
const container = messagesContainerRef.current;
|
||||||
|
const diff = container.scrollHeight - prevScrollHeightRef.current;
|
||||||
|
container.scrollTop += diff;
|
||||||
|
prevScrollHeightRef.current = 0;
|
||||||
|
}
|
||||||
|
}, [chatMessages.length]);
|
||||||
|
|
||||||
// Scroll on new message arrivals
|
// Scroll on new message arrivals
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -316,6 +328,12 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
checkScrollPosition();
|
checkScrollPosition();
|
||||||
|
|
||||||
|
const container = messagesContainerRef.current;
|
||||||
|
if (container && container.scrollTop < 100 && activeChat && hasMoreMessages[activeChat] && !isLoadingMessages) {
|
||||||
|
prevScrollHeightRef.current = container.scrollHeight;
|
||||||
|
useChatStore.getState().loadMessages(activeChat, false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -851,7 +869,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
className={`flex-1 overflow-y-auto px-6 pt-6 pb-2 relative z-10 ${!scrollReady && !isLoadingMessages && chatMessages.length > 0 ? 'invisible' : ''}`}
|
className={`flex-1 overflow-y-auto px-6 pt-6 pb-2 relative z-10 ${!scrollReady && !isLoadingMessages && chatMessages.length > 0 ? 'invisible' : ''}`}
|
||||||
>
|
>
|
||||||
{isLoadingMessages ? (
|
{isLoadingMessages && chatMessages.length === 0 ? (
|
||||||
<div className="flex justify-center py-8">
|
<div className="flex justify-center py-8">
|
||||||
<div className="w-6 h-6 border-2 border-knot-500 border-t-transparent rounded-full animate-spin" />
|
<div className="w-6 h-6 border-2 border-knot-500 border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
@@ -861,6 +879,11 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1 max-w-3xl mx-auto">
|
<div className="space-y-1 max-w-3xl mx-auto">
|
||||||
|
{isLoadingMessages && (
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<div className="w-5 h-5 border-2 border-knot-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{chatMessages.map((msg, i) => {
|
{chatMessages.map((msg, i) => {
|
||||||
const prevMsg = i > 0 ? chatMessages[i - 1] : null;
|
const prevMsg = i > 0 ? chatMessages[i - 1] : null;
|
||||||
const showAvatar = !prevMsg || prevMsg.senderId !== msg.senderId;
|
const showAvatar = !prevMsg || prevMsg.senderId !== msg.senderId;
|
||||||
|
|||||||
@@ -431,7 +431,7 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro
|
|||||||
<div className="px-5 py-3 border-t border-border mt-2">
|
<div className="px-5 py-3 border-t border-border mt-2">
|
||||||
<h4 className="text-xs text-zinc-500 uppercase tracking-wide mb-3">Хранилище и данные</h4>
|
<h4 className="text-xs text-zinc-500 uppercase tracking-wide mb-3">Хранилище и данные</h4>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowImportModal(true); }}
|
onClick={() => { loadFriends(); setShowImportModal(true); }}
|
||||||
className="w-full flex items-center gap-4 px-3 py-3 rounded-xl bg-surface-tertiary/50 hover:bg-surface-hover transition-colors"
|
className="w-full flex items-center gap-4 px-3 py-3 rounded-xl bg-surface-tertiary/50 hover:bg-surface-hover transition-colors"
|
||||||
>
|
>
|
||||||
<div className="w-8 h-8 rounded-lg bg-blue-500/20 flex items-center justify-center">
|
<div className="w-8 h-8 rounded-lg bg-blue-500/20 flex items-center justify-center">
|
||||||
|
|||||||
@@ -189,13 +189,11 @@ export default function TelegramImportModal({ isOpen, onClose, friends }: Telegr
|
|||||||
>
|
>
|
||||||
<option value="">-- Выберите пользователя --</option>
|
<option value="">-- Выберите пользователя --</option>
|
||||||
<option value={user?.id}>Это я ({user?.displayName || user?.username})</option>
|
<option value={user?.id}>Это я ({user?.displayName || user?.username})</option>
|
||||||
<optgroup label="Мои контакты">
|
{friends.map(f => (
|
||||||
{friends.map(f => (
|
<option key={f.id} value={f.id}>
|
||||||
<option key={f.id} value={f.id}>
|
Контакт: {f.displayName || f.username}
|
||||||
Контакт: {f.displayName || f.username}
|
</option>
|
||||||
</option>
|
))}
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -15,13 +15,14 @@ interface ChatState {
|
|||||||
isLoadingMessages: boolean;
|
isLoadingMessages: boolean;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
drafts: Record<string, string>;
|
drafts: Record<string, string>;
|
||||||
|
hasMoreMessages: Record<string, boolean>;
|
||||||
|
|
||||||
setActiveChat: (chatId: string | null) => void;
|
setActiveChat: (chatId: string | null) => void;
|
||||||
setSearchQuery: (query: string) => void;
|
setSearchQuery: (query: string) => void;
|
||||||
setDraft: (chatId: string, text: string) => void;
|
setDraft: (chatId: string, text: string) => void;
|
||||||
getDraft: (chatId: string) => string;
|
getDraft: (chatId: string) => string;
|
||||||
loadChats: () => Promise<void>;
|
loadChats: () => Promise<void>;
|
||||||
loadMessages: (chatId: string) => Promise<void>;
|
loadMessages: (chatId: string, reset?: boolean) => Promise<void>;
|
||||||
addMessage: (message: Message) => void;
|
addMessage: (message: Message) => void;
|
||||||
updateMessage: (message: Message) => void;
|
updateMessage: (message: Message) => void;
|
||||||
removeMessage: (messageId: string, chatId: string) => void;
|
removeMessage: (messageId: string, chatId: string) => void;
|
||||||
@@ -56,6 +57,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
isLoadingMessages: false,
|
isLoadingMessages: false,
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
drafts: JSON.parse(localStorage.getItem('knot_drafts') || '{}'),
|
drafts: JSON.parse(localStorage.getItem('knot_drafts') || '{}'),
|
||||||
|
hasMoreMessages: {},
|
||||||
|
|
||||||
setActiveChat: (chatId) => set((state) => ({
|
setActiveChat: (chatId) => set((state) => ({
|
||||||
activeChat: chatId,
|
activeChat: chatId,
|
||||||
@@ -111,13 +113,21 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
loadMessages: async (chatId) => {
|
loadMessages: async (chatId, reset = false) => {
|
||||||
try {
|
try {
|
||||||
|
const state = get();
|
||||||
|
if (!reset && state.messages[chatId] && state.hasMoreMessages[chatId] === false) return;
|
||||||
|
if (state.isLoadingMessages) return;
|
||||||
|
|
||||||
set({ isLoadingMessages: true });
|
set({ isLoadingMessages: true });
|
||||||
const fetched = await api.getMessages(chatId);
|
const currentMessages = state.messages[chatId] || [];
|
||||||
|
const cursor = !reset && currentMessages.length > 0 ? currentMessages[0].createdAt : undefined;
|
||||||
|
|
||||||
|
const fetched = await api.getMessages(chatId, cursor);
|
||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
// Merge fetched messages with any that arrived via socket during the fetch
|
// Merge fetched messages with any that arrived via socket
|
||||||
const existing = state.messages[chatId] || [];
|
const existing = reset ? [] : (state.messages[chatId] || []);
|
||||||
const fetchedIds = new Set(fetched.map(m => m.id));
|
const fetchedIds = new Set(fetched.map(m => m.id));
|
||||||
const socketOnly = existing.filter(m => !fetchedIds.has(m.id));
|
const socketOnly = existing.filter(m => !fetchedIds.has(m.id));
|
||||||
const merged = [...fetched, ...socketOnly].sort(
|
const merged = [...fetched, ...socketOnly].sort(
|
||||||
@@ -125,6 +135,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
messages: { ...state.messages, [chatId]: merged },
|
messages: { ...state.messages, [chatId]: merged },
|
||||||
|
hasMoreMessages: { ...state.hasMoreMessages, [chatId]: fetched.length === 100 },
|
||||||
isLoadingMessages: false,
|
isLoadingMessages: false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user