Заготовка опросов

This commit is contained in:
Халимов Рустам
2026-04-07 01:01:29 +03:00
parent 02a85fc587
commit c45f4db61c
23 changed files with 824 additions and 249 deletions
@@ -63,6 +63,9 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery,
}
}
var pinnedMessages = await _messageRepository.GetPinnedMessagesAsync(chat.Id, cancellationToken);
foreach (var pm in pinnedMessages) userIdsToFetch.Add(pm.SenderId);
var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
var members = new List<ChatMemberDto>();
@@ -88,55 +91,19 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery,
var messagesList = new List<ChatMessageDto>();
if (latestMessage != null)
{
usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
messagesList.Add(MessageMapper.MapToDto(
latestMessage,
usersInfo,
latestReactions,
chat.Members.Where(m => m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId).Select(m => m.UserId)));
}
var reactionsWithUser = new List<ReactionDto>();
foreach (var reaction in latestReactions)
{
usersInfo.TryGetValue(reaction.UserId, out var reactionUser);
reactionsWithUser.Add(new ReactionDto(
reaction.Id,
reaction.Emoji,
reaction.UserId,
reactionUser != null
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null)
));
}
var readByList = chat.Members
.Where(m => m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId)
.Select(m => new ReadByDto(m.UserId))
.ToList();
var textMessage = latestMessage as TextMessage;
var mediaMessage = latestMessage as MediaMessage;
var storyMessage = latestMessage as StoryMessage;
messagesList.Add(new ChatMessageDto(
latestMessage.Id,
latestMessage.ChatId,
latestMessage.SenderId,
latestMessage.Content,
latestMessage.Type,
latestMessage.ReplyToId,
textMessage?.Quote,
storyMessage?.StoryId,
storyMessage?.StoryMediaUrl,
storyMessage?.StoryMediaType,
latestMessage.IsEdited,
latestMessage.IsDeleted,
latestMessage.CreatedAt,
latestMessage.SequenceId,
mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() ?? new List<MediaDto>(),
senderObj != null ? new MessageSenderDto(
senderObj.Id,
senderObj.Username,
senderObj.DisplayName,
senderObj.Avatar
) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
reactionsWithUser,
readByList
var pinnedDtoList = new List<PinnedMessageDto>();
foreach (var pm in pinnedMessages)
{
pinnedDtoList.Add(new PinnedMessageDto(
pm.Id,
MessageMapper.MapToDto(pm, usersInfo, new List<MessageReaction>(), new List<Guid>())
));
}
@@ -152,6 +119,7 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery,
chat.CreatedAt,
members,
messagesList,
pinnedDtoList,
unreadCount
);
@@ -59,6 +59,9 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
}
}
var pinnedMessages = await _messageRepository.GetPinnedMessagesAsync(chat.Id, cancellationToken);
foreach (var pm in pinnedMessages) userIdsToFetch.Add(pm.SenderId);
var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
var members = new List<ChatMemberDto>();
@@ -85,53 +88,19 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
if (latestMessage != null)
{
usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
messagesList.Add(MessageMapper.MapToDto(
latestMessage,
usersInfo,
latestReactions,
chat.Members.Where(m => m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId).Select(m => m.UserId)));
}
var reactionsWithUser = new List<ReactionDto>();
foreach (var reaction in latestReactions)
{
usersInfo.TryGetValue(reaction.UserId, out var reactionUser);
reactionsWithUser.Add(new ReactionDto(
reaction.Id,
reaction.Emoji,
reaction.UserId,
reactionUser != null
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null)
));
}
var textMessage = latestMessage as TextMessage;
var mediaMessage = latestMessage as MediaMessage;
var storyMessage = latestMessage as StoryMessage;
messagesList.Add(new ChatMessageDto(
latestMessage.Id,
latestMessage.ChatId,
latestMessage.SenderId,
latestMessage.Content,
latestMessage.Type,
latestMessage.ReplyToId,
textMessage?.Quote,
storyMessage?.StoryId,
storyMessage?.StoryMediaUrl,
storyMessage?.StoryMediaType,
latestMessage.IsEdited,
latestMessage.IsDeleted,
latestMessage.CreatedAt,
latestMessage.SequenceId,
mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() ?? new List<MediaDto>(),
senderObj != null ? new MessageSenderDto(
senderObj.Id,
senderObj.Username,
senderObj.DisplayName,
senderObj.Avatar
) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
reactionsWithUser,
chat.Members.Where(m => m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId).Select(m => new ReadByDto(m.UserId)).ToList(),
(latestMessage as CallMessage)?.CallType,
(latestMessage as CallMessage)?.CallStatus,
(latestMessage as CallMessage)?.Duration
var pinnedDtoList = new List<PinnedMessageDto>();
foreach (var pm in pinnedMessages)
{
pinnedDtoList.Add(new PinnedMessageDto(
pm.Id,
MessageMapper.MapToDto(pm, usersInfo, new List<MessageReaction>(), new List<Guid>())
));
}
@@ -147,6 +116,7 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
chat.CreatedAt,
members,
messagesList,
pinnedDtoList,
unreadCount,
chat.IsImporting,
chat.ImportJobId
@@ -12,6 +12,7 @@ public record ChatDto(
DateTime CreatedAt,
List<ChatMemberDto> Members,
List<ChatMessageDto> Messages,
List<PinnedMessageDto> PinnedMessages,
int UnreadCount,
bool IsImporting = false,
Guid? ImportJobId = null
@@ -24,6 +24,12 @@ public record ChatMessageDto(
List<ReadByDto> ReadBy,
string? CallType = null,
string? CallStatus = null,
int? Duration = null
int? Duration = null,
List<PollOptionDto>? PollOptions = null,
bool? PollIsMultipleChoice = null,
bool? PollIsClosed = null
);
public record PollOptionDto(string Text, int VoteCount);
@@ -7,6 +7,6 @@ public record MediaDto(
string Type,
string? Url,
string? Filename,
long? Size
long? Size,
string? Duration = null
);
@@ -0,0 +1,68 @@
using Knot.Shared.Kernel;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.DTOs;
namespace Knot.Modules.Conversations.Application.DTOs;
public static class MessageMapper
{
public static ChatMessageDto MapToDto(
Message message,
IReadOnlyDictionary<Guid, UserInfo> usersInfo,
IEnumerable<MessageReaction> reactions,
IEnumerable<Guid> readByUsers)
{
usersInfo.TryGetValue(message.SenderId, out var senderObj);
var reactionsWithUser = new List<ReactionDto>();
foreach (var reaction in reactions)
{
usersInfo.TryGetValue(reaction.UserId, out var reactionUser);
reactionsWithUser.Add(new ReactionDto(
reaction.Id,
reaction.Emoji,
reaction.UserId,
reactionUser != null
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null)
));
}
var textMessage = message as TextMessage;
var mediaMessage = message as MediaMessage;
var storyMessage = message as StoryMessage;
var callMessage = message as CallMessage;
return new ChatMessageDto(
message.Id,
message.ChatId,
message.SenderId,
message.Content,
message.Type,
message.ReplyToId,
textMessage?.Quote,
storyMessage?.StoryId,
storyMessage?.StoryMediaUrl,
storyMessage?.StoryMediaType,
message.IsEdited,
message.IsDeleted,
message.CreatedAt,
message.SequenceId,
mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size, media.Duration)).ToList() ?? new List<MediaDto>(),
senderObj != null ? new MessageSenderDto(
senderObj.Id,
senderObj.Username,
senderObj.DisplayName,
senderObj.Avatar
) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
reactionsWithUser,
readByUsers.Select(id => new ReadByDto(id)).ToList(),
callMessage?.CallType,
callMessage?.CallStatus,
callMessage?.Duration,
(message as PollMessage)?.Options.Select(o => new PollOptionDto(o.Text, o.VoteCount)).ToList(),
(message as PollMessage)?.IsMultipleChoice,
(message as PollMessage)?.IsClosed
);
}
}
@@ -0,0 +1,6 @@
namespace Knot.Modules.Conversations.Application.DTOs;
public record PinnedMessageDto(
Guid Id,
ChatMessageDto Message
);
@@ -97,7 +97,7 @@ internal sealed class GetSharedMediaQueryHandler : IQueryHandler<GetSharedMediaQ
if (filterType == "gifs") return isGif;
if (filterType == "media") return (mType == "image" || mType == "video") && !isGif;
if (filterType == "files") return mType == "file" || (mType != "image" && mType != "video" && mType != "link" && !isGif);
if (filterType == "files") return (mType == "file" || mType == "audio") && !isGif && mType != "image" && mType != "video";
if (filterType == "links") return mType == "link";
return true;
@@ -119,7 +119,7 @@ internal sealed class GetSharedMediaQueryHandler : IQueryHandler<GetSharedMediaQ
storyMessage?.StoryMediaType,
message.IsEdited,
message.Type,
filteredMedia.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList()
filteredMedia.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size, media.Duration)).ToList()
));
}
}
@@ -0,0 +1,58 @@
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Conversations.Application.Messages.Pin;
public sealed record PinMessageCommand(Guid MessageId, Guid ChatId, Guid UserId) : ICommand<Guid>;
public sealed class PinMessageCommandHandler : ICommandHandler<PinMessageCommand, Guid>
{
private readonly IMessageRepository _messageRepository;
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _unitOfWork;
private readonly IMediator _mediator;
public PinMessageCommandHandler(
IMessageRepository messageRepository,
IChatRepository chatRepository,
IChatsUnitOfWork unitOfWork,
IMediator mediator)
{
_messageRepository = messageRepository;
_chatRepository = chatRepository;
_unitOfWork = unitOfWork;
_mediator = mediator;
}
public async Task<Result<Guid>> Handle(PinMessageCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat is null) return Result.Failure<Guid>(ChatErrors.ChatsNotFound);
// Security check
if (!chat.Members.Any(m => m.UserId == request.UserId))
return Result.Failure<Guid>(ChatErrors.ChatsForbidden);
var message = await _messageRepository.GetByIdAsync(request.MessageId, cancellationToken);
if (message is null) return Result.Failure<Guid>(ChatErrors.NotFound);
if (message.ChatId != request.ChatId)
return Result.Failure<Guid>(ChatErrors.NotFound);
message.AddState(MessageState.IsPinned);
await _messageRepository.UpdateAsync(message, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
// Notify chat about pinned message change
await _mediator.Publish(new MessagePinnedDomainEvent(message.Id, message.ChatId, message.SenderId, message.Content), cancellationToken);
return Result.Success(message.Id);
}
}
public record MessagePinnedDomainEvent(Guid MessageId, Guid ChatId, Guid SenderId, string? Content) : INotification;
@@ -0,0 +1,58 @@
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Conversations.Application.Messages.Unpin;
public sealed record UnpinMessageCommand(Guid MessageId, Guid ChatId, Guid UserId) : ICommand<Guid>;
public sealed class UnpinMessageCommandHandler : ICommandHandler<UnpinMessageCommand, Guid>
{
private readonly IMessageRepository _messageRepository;
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _unitOfWork;
private readonly IMediator _mediator;
public UnpinMessageCommandHandler(
IMessageRepository messageRepository,
IChatRepository chatRepository,
IChatsUnitOfWork unitOfWork,
IMediator mediator)
{
_messageRepository = messageRepository;
_chatRepository = chatRepository;
_unitOfWork = unitOfWork;
_mediator = mediator;
}
public async Task<Result<Guid>> Handle(UnpinMessageCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat is null) return Result.Failure<Guid>(ChatErrors.ChatsNotFound);
// Security check
if (!chat.Members.Any(m => m.UserId == request.UserId))
return Result.Failure<Guid>(ChatErrors.ChatsForbidden);
var message = await _messageRepository.GetByIdAsync(request.MessageId, cancellationToken);
if (message is null) return Result.Failure<Guid>(ChatErrors.NotFound);
if (message.ChatId != request.ChatId)
return Result.Failure<Guid>(ChatErrors.NotFound);
message.RemoveState(MessageState.IsPinned);
await _messageRepository.UpdateAsync(message, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
// Notify chat about unpinned message change
await _mediator.Publish(new MessageUnpinnedDomainEvent(message.Id, message.ChatId), cancellationToken);
return Result.Success(message.Id);
}
}
public record MessageUnpinnedDomainEvent(Guid MessageId, Guid ChatId) : INotification;
@@ -13,6 +13,12 @@ using Knot.Shared.Kernel;
using Microsoft.Extensions.Caching.Memory;
using Knot.Contracts.Auth.Domain;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Modules.Conversations.Application.Messages.Pin;
using Knot.Modules.Conversations.Application.Messages.Unpin;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
namespace Knot.Modules.Conversations.Infrastructure.SignalR;
@@ -39,17 +45,29 @@ public sealed class ChatHub : Hub
private readonly IUserContext _userContext;
private readonly IChatRepository _chatRepository;
private readonly IUserRepository _userRepository;
private readonly IMessageRepository _messageRepository;
private readonly ILogger<ChatHub> _logger;
private readonly IMemoryCache _cache;
private readonly IUserDisplayNameProvider _userProvider;
public ChatHub(ISender sender, IUserContext userContext, IChatRepository chatRepository, IUserRepository userRepository, ILogger<ChatHub> logger, IMemoryCache cache)
public ChatHub(
ISender sender,
IUserContext userContext,
IChatRepository chatRepository,
IUserRepository userRepository,
IMessageRepository messageRepository,
ILogger<ChatHub> logger,
IMemoryCache cache,
IUserDisplayNameProvider userProvider)
{
_sender = sender;
_userContext = userContext;
_chatRepository = chatRepository;
_userRepository = userRepository;
_messageRepository = messageRepository;
_logger = logger;
_cache = cache;
_userProvider = userProvider;
}
public override async Task OnConnectedAsync()
@@ -112,14 +130,18 @@ public sealed class ChatHub : Hub
new AttachmentRequest(a.Type, a.Url, a.FileName, a.FileSize)).ToList();
var command = new SendMessageCommand(
request.ChatId,
_userContext.UserId,
request.Content,
request.Type,
attachments,
request.ReplyToId,
request.Quote,
request.ForwardedFromId);
ChatId: request.ChatId,
SenderId: _userContext.UserId,
Content: request.Content,
Type: request.Type,
Attachments: attachments,
ReplyToId: request.ReplyToId,
Quote: request.Quote,
ForwardedFromId: request.ForwardedFromId,
PollOptions: request.PollOptions,
PollIsAnonymous: request.PollIsAnonymous,
PollAllowMultipleAnswers: request.PollAllowMultipleAnswers
);
await _sender.Send(command);
}
@@ -227,6 +249,40 @@ public sealed class ChatHub : Hub
_logger.LogInformation("RemoveReaction completed successfully");
}
[HubMethodName("pin_message")]
public async Task PinMessage(PinMessageRequest request)
{
var command = new PinMessageCommand(request.MessageId, request.ChatId, _userContext.UserId);
var result = await _sender.Send(command);
var message = await _messageRepository.GetByIdAsync(request.MessageId, Context.ConnectionAborted);
if (message != null)
{
var senderInfo = await _userProvider.GetUsersInfoAsync(new[] { message.SenderId });
var dto = MessageMapper.MapToDto(message, senderInfo, Enumerable.Empty<MessageReaction>(), Enumerable.Empty<Guid>());
await Clients.Group(request.ChatId.ToString()).SendAsync("message_pinned", new
{
chatId = request.ChatId,
message = dto,
userId = _userContext.UserId
});
}
}
[HubMethodName("unpin_message")]
public async Task UnpinMessage(PinMessageRequest request)
{
var command = new UnpinMessageCommand(request.MessageId, request.ChatId, _userContext.UserId);
var result = await _sender.Send(command);
await Clients.Group(request.ChatId.ToString()).SendAsync("message_unpinned", new
{
chatId = request.ChatId,
messageId = request.MessageId,
userId = _userContext.UserId
});
}
// ────────────────────────────────────────────────────────────────
// Friend signals (Proxy methods for real-time notification)
// ────────────────────────────────────────────────────────────────
@@ -648,7 +704,7 @@ public sealed class ChatHub : Hub
await Clients.Group(request.ChatId).SendAsync("group_call_status_updated", new
{
chatId = request.ChatId,
userId = Context.UserIdentifier,
userId = _userContext.UserId.ToString(),
isMuted = request.IsMuted,
isVideoOff = request.IsVideoOff
});
@@ -675,7 +731,7 @@ public sealed class ChatHub : Hub
await Clients.Group(chatId).SendAsync("group_call_status_updated", new
{
chatId = chatId,
userId = Context.UserIdentifier,
userId = _userContext.UserId.ToString(),
isMuted = isMuted,
isVideoOff = isVideoOff
});
@@ -759,7 +815,10 @@ public sealed class ChatHub : Hub
List<AttachmentHubRequest>? Attachments = null,
Guid? ReplyToId = null,
string? Quote = null,
Guid? ForwardedFromId = null);
Guid? ForwardedFromId = null,
List<string>? PollOptions = null,
bool? PollIsAnonymous = null,
bool? PollAllowMultipleAnswers = null);
public record ReadMessagesRequest(Guid ChatId, Guid LastReadMessageId, long LastReadSequenceId);
public record CallOfferRequest(string TargetUserId, object Offer, string CallType, string? ChatId);
public record CallAnswerRequest(string TargetUserId, object Answer);
@@ -771,6 +830,7 @@ public sealed class ChatHub : Hub
public record CallStatusRequest(string TargetUserId, bool IsMuted, bool IsVideoOff);
public record AddReactionRequest(Guid MessageId, Guid ChatId, string Emoji);
public record RemoveReactionRequest(Guid MessageId, Guid ChatId, string Emoji);
public record PinMessageRequest(Guid MessageId, Guid ChatId);
public record DeleteMessagesHubRequest(Guid ChatId, List<string> MessageIds, bool DeleteForAll);
public record GroupCallJoinRequest(string ChatId, string CallType);
public record ParticipantInfo(string Id, string Username, string DisplayName, string? Avatar, bool IsSharingScreen = false, bool IsMuted = false, bool IsVideoOff = false);
@@ -62,6 +62,19 @@ public sealed class MessageRepository : IMessageRepository
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<List<Message>> GetPinnedMessagesAsync(Guid chatId, CancellationToken cancellationToken)
{
var builder = Builders<Message>.Filter;
var filter = builder.And(
builder.Eq(m => m.ChatId, chatId),
builder.BitsAnySet(m => m.State, (long)MessageState.IsPinned)
);
return await _messages.Find(filter)
.SortByDescending(m => m.CreatedAt)
.ToListAsync(cancellationToken);
}
public async Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, long? sequenceId, int limit, CancellationToken cancellationToken)
{
var builder = Builders<Message>.Filter;
@@ -190,7 +190,7 @@ public sealed class TelegramHtmlParser : ITelegramHtmlParser
string fileName = "";
var titleNode = link.QuerySelector(".title") ?? link.QuerySelector(".name") ?? link.QuerySelector(".description");
if (titleNode != null)
if (titleNode != null && !titleNode.TextContent.Contains(":") && titleNode.TextContent.Length < 100)
{
fileName = titleNode.TextContent.Trim();
}
@@ -202,7 +202,8 @@ public sealed class TelegramHtmlParser : ITelegramHtmlParser
{
var clone = (IElement)bodyNode.Clone();
foreach (var s in clone.QuerySelectorAll(".status, .details, .pull_right")) s.Remove();
fileName = clone.TextContent.Trim();
var candidate = clone.TextContent.Trim();
if (candidate.Length > 0 && candidate.Length < 100 && !candidate.Contains(":")) fileName = candidate;
}
// Final desperate attempt: link's own content excluding status tags
@@ -210,7 +211,8 @@ public sealed class TelegramHtmlParser : ITelegramHtmlParser
{
var clone = (IElement)link.Clone();
foreach (var s in clone.QuerySelectorAll(".status, .details, .pull_right, .details_icon")) s.Remove();
fileName = clone.TextContent.Trim();
var candidate = clone.TextContent.Trim();
if (candidate.Length > 0 && candidate.Length < 100 && !candidate.Contains(":")) fileName = candidate;
}
}