diff --git a/source/src/MyTelegram.DataSeeder/MyMessengerJsonContext.g.cs b/source/src/MyTelegram.DataSeeder/MyMessengerJsonContext.g.cs index 21de9e5ca..c60671a88 100644 --- a/source/src/MyTelegram.DataSeeder/MyMessengerJsonContext.g.cs +++ b/source/src/MyTelegram.DataSeeder/MyMessengerJsonContext.g.cs @@ -260,6 +260,7 @@ namespace MyTelegram.Messenger.NativeAot; [JsonSerializable(typeof(MyTelegram.Domain.Sagas.PostChannelIdUpdatedSagaEvent))] [JsonSerializable(typeof(MyTelegram.Domain.Sagas.ReadHistoryPtsIncrementedSagaEvent))] [JsonSerializable(typeof(MyTelegram.Domain.Sagas.ReadHistoryStartedSagaEvent))] +[JsonSerializable(typeof(MyTelegram.Domain.Sagas.ReadMentionsCompletedSagaEvent))] [JsonSerializable(typeof(MyTelegram.Domain.Sagas.ReplyBroadcastChannelCompletedSagaEvent))] [JsonSerializable(typeof(MyTelegram.Domain.Sagas.SetDiscussionGroupSagaStartedSagaEvent))] [JsonSerializable(typeof(MyTelegram.Domain.Sagas.UnpinAllMessagesCompletedSagaEvent))] diff --git a/source/src/MyTelegram.Domain/Aggregates/Dialog/DialogAggregate.cs b/source/src/MyTelegram.Domain/Aggregates/Dialog/DialogAggregate.cs index 372b81f82..bf38af3c4 100644 --- a/source/src/MyTelegram.Domain/Aggregates/Dialog/DialogAggregate.cs +++ b/source/src/MyTelegram.Domain/Aggregates/Dialog/DialogAggregate.cs @@ -156,16 +156,16 @@ int date toPeer, date)); } - public void ReadMention(int messageId) + public void ReadMention(RequestInfo requestInfo, int messageId, bool readAllMentions = false) { Specs.AggregateIsCreated.ThrowDomainErrorIfNotSatisfied(this); - var unreadMentionsCount = _state.UnreadMentionsCount - 1; + var unreadMentionsCount = readAllMentions ? 0 : _state.UnreadMentionsCount - 1; if (unreadMentionsCount < 0) { unreadMentionsCount = 0; } - Emit(new MentionReadEvent(_state.OwnerId, _state.ToPeer, messageId, unreadMentionsCount)); + Emit(new MentionReadEvent(requestInfo, _state.OwnerId, _state.ToPeer, messageId, unreadMentionsCount)); } public void ReceiveInboxMessage( diff --git a/source/src/MyTelegram.Domain/CommandHandlers/Dialog/ReadMentionCommandHandler.cs b/source/src/MyTelegram.Domain/CommandHandlers/Dialog/ReadMentionCommandHandler.cs index 6a9195ec8..bcbb1b5f0 100644 --- a/source/src/MyTelegram.Domain/CommandHandlers/Dialog/ReadMentionCommandHandler.cs +++ b/source/src/MyTelegram.Domain/CommandHandlers/Dialog/ReadMentionCommandHandler.cs @@ -4,7 +4,7 @@ public class ReadMentionCommandHandler : CommandHandler(aggregateId) +public class ReadMentionCommand( + DialogId aggregateId, + RequestInfo requestInfo, + long ownerUserId, + int messageId, + bool readAllMentions = false) + : RequestCommand2(aggregateId, requestInfo) { public long OwnerUserId { get; } = ownerUserId; //public long ToPeerId { get; } public int MessageId { get; } = messageId; + public bool ReadAllMentions { get; } = readAllMentions; + /*long toPeerId,*/ //ToPeerId = toPeerId; } \ No newline at end of file diff --git a/source/src/MyTelegram.Domain/Events/Dialog/MentionReadEvent.cs b/source/src/MyTelegram.Domain/Events/Dialog/MentionReadEvent.cs index 39f944ad6..998617842 100644 --- a/source/src/MyTelegram.Domain/Events/Dialog/MentionReadEvent.cs +++ b/source/src/MyTelegram.Domain/Events/Dialog/MentionReadEvent.cs @@ -1,7 +1,12 @@ namespace MyTelegram.Domain.Events.Dialog; -public class MentionReadEvent(long ownerUserId, Peer toPeer, int messageId, int unreadMentionsCount) - : AggregateEvent +public class MentionReadEvent( + RequestInfo requestInfo, + long ownerUserId, + Peer toPeer, + int messageId, + int unreadMentionsCount) + : RequestAggregateEvent2(requestInfo) { public long OwnerUserId { get; } = ownerUserId; public Peer ToPeer { get; } = toPeer; diff --git a/source/src/MyTelegram.Domain/Sagas/Identities/ReadMentionsSagaId.cs b/source/src/MyTelegram.Domain/Sagas/Identities/ReadMentionsSagaId.cs new file mode 100644 index 000000000..d940aea7a --- /dev/null +++ b/source/src/MyTelegram.Domain/Sagas/Identities/ReadMentionsSagaId.cs @@ -0,0 +1,4 @@ +namespace MyTelegram.Domain.Sagas.Identities; + +[JsonConverter(typeof(SystemTextJsonSingleValueObjectConverter))] +public class ReadMentionsSagaId(string value) : SingleValueObject(value), ISagaId; diff --git a/source/src/MyTelegram.Domain/Sagas/Identities/ReadMentionsSagaLocator.cs b/source/src/MyTelegram.Domain/Sagas/Identities/ReadMentionsSagaLocator.cs new file mode 100644 index 000000000..fea550209 --- /dev/null +++ b/source/src/MyTelegram.Domain/Sagas/Identities/ReadMentionsSagaLocator.cs @@ -0,0 +1,9 @@ +namespace MyTelegram.Domain.Sagas.Identities; + +public class ReadMentionsSagaLocator : DefaultSagaLocator +{ + protected override ReadMentionsSagaId CreateSagaId(string requestId) + { + return new ReadMentionsSagaId(requestId); + } +} diff --git a/source/src/MyTelegram.Domain/Sagas/ReadMentionsSaga.cs b/source/src/MyTelegram.Domain/Sagas/ReadMentionsSaga.cs new file mode 100644 index 000000000..a765e8e7e --- /dev/null +++ b/source/src/MyTelegram.Domain/Sagas/ReadMentionsSaga.cs @@ -0,0 +1,50 @@ +namespace MyTelegram.Domain.Sagas; + +public class ReadMentionsCompletedSagaEvent( + RequestInfo requestInfo, + long userId, + Peer toPeer, + int messageId, + int unreadMentionsCount, + int pts) + : RequestAggregateEvent2(requestInfo) +{ + public long UserId { get; } = userId; + public Peer ToPeer { get; } = toPeer; + public int MessageId { get; } = messageId; + public int UnreadMentionsCount { get; } = unreadMentionsCount; + public int Pts { get; } = pts; + public int PtsCount { get; } = 0; +} + +public class ReadMentionsSaga : MyInMemoryAggregateSaga, + ISagaIsStartedBy +{ + private readonly IIdGenerator _idGenerator; + + public ReadMentionsSaga(ReadMentionsSagaId id, IEventStore eventStore, IIdGenerator idGenerator) : base(id, eventStore) + { + _idGenerator = idGenerator; + } + + public async Task HandleAsync( + IDomainEvent domainEvent, + ISagaContext sagaContext, + CancellationToken cancellationToken) + { + var pts = await _idGenerator.NextIdAsync( + IdType.Pts, + domainEvent.AggregateEvent.OwnerUserId, + cancellationToken: cancellationToken); + + Emit(new ReadMentionsCompletedSagaEvent( + domainEvent.AggregateEvent.RequestInfo, + domainEvent.AggregateEvent.OwnerUserId, + domainEvent.AggregateEvent.ToPeer, + domainEvent.AggregateEvent.MessageId, + domainEvent.AggregateEvent.UnreadMentionsCount, + pts)); + + await CompleteAsync(cancellationToken); + } +} diff --git a/source/src/MyTelegram.Domain/Sagas/SendMessageSaga.cs b/source/src/MyTelegram.Domain/Sagas/SendMessageSaga.cs index d6d2e6990..b2ba862e3 100644 --- a/source/src/MyTelegram.Domain/Sagas/SendMessageSaga.cs +++ b/source/src/MyTelegram.Domain/Sagas/SendMessageSaga.cs @@ -25,6 +25,7 @@ public class SendMessageSaga : MyInMemoryAggregateSaga { private readonly IIdGenerator _idGenerator; + private bool _draftCleared; private readonly SendMessageSagaState _state = new(); public SendMessageSaga(SendMessageSagaId id, IEventStore eventStore, IIdGenerator idGenerator) : base(id, eventStore) { @@ -121,6 +122,15 @@ public async Task HandleAsync(IDomainEvent, ISubscribeSynchronousTo, ISubscribeSynchronousTo, + ISubscribeSynchronousTo, ISubscribeSynchronousTo, ISubscribeSynchronousTo, ISubscribeSynchronousTo @@ -134,6 +135,15 @@ public async Task HandleAsync(IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + return UpdatePtsAsync(domainEvent.AggregateEvent.UserId, + domainEvent.AggregateEvent.Pts, + domainEvent.AggregateEvent.PtsCount); + } + private async Task IncrementGlobalSeqNoAsync(long userId) { var globalSeqNo = await idGenerator.NextLongIdAsync(IdType.GlobalSeqNo); diff --git a/source/src/MyTelegram.Messenger.CommandServer/EventHandlers/LogOutEventHandler.cs b/source/src/MyTelegram.Messenger.CommandServer/EventHandlers/LogOutEventHandler.cs new file mode 100644 index 000000000..8229d6b5a --- /dev/null +++ b/source/src/MyTelegram.Messenger.CommandServer/EventHandlers/LogOutEventHandler.cs @@ -0,0 +1,19 @@ +using MyTelegram.Domain.Aggregates.Device; +using MyTelegram.Domain.Commands.Device; + +namespace MyTelegram.Messenger.CommandServer.EventHandlers; + +public class LogOutEventHandler( + ICommandBus commandBus + ) : IEventHandler, + ITransientDependency +{ + public async Task HandleEventAsync(UserLoggedOutEvent eventData) + { + var command = new UnRegisterDeviceForAuthKeyCommand( + DeviceId.Create(eventData.PermAuthKeyId), + eventData.PermAuthKeyId, + eventData.TempAuthKeyId); + await commandBus.PublishAsync(command, CancellationToken.None); + } +} diff --git a/source/src/MyTelegram.Messenger.CommandServer/Extensions/MyTelegramMessengerCommandServerExtensions.cs b/source/src/MyTelegram.Messenger.CommandServer/Extensions/MyTelegramMessengerCommandServerExtensions.cs index d2eb05018..291a13baa 100644 --- a/source/src/MyTelegram.Messenger.CommandServer/Extensions/MyTelegramMessengerCommandServerExtensions.cs +++ b/source/src/MyTelegram.Messenger.CommandServer/Extensions/MyTelegramMessengerCommandServerExtensions.cs @@ -22,6 +22,7 @@ public static void AddEventHandlers(this IServiceCollection services) services.AddSubscription(); services.AddSubscription(); + services.AddSubscription(); } public static void AddMyTelegramMessengerCommandServer(this IServiceCollection services, diff --git a/source/src/MyTelegram.Messenger.QueryServer/DomainEventHandlers/ChannelMessageViewsDomainEventHandler.cs b/source/src/MyTelegram.Messenger.QueryServer/DomainEventHandlers/ChannelMessageViewsDomainEventHandler.cs index 737832eba..19faddd04 100644 --- a/source/src/MyTelegram.Messenger.QueryServer/DomainEventHandlers/ChannelMessageViewsDomainEventHandler.cs +++ b/source/src/MyTelegram.Messenger.QueryServer/DomainEventHandlers/ChannelMessageViewsDomainEventHandler.cs @@ -13,7 +13,6 @@ public async Task HandleAsync(IDomainEvent, ISubscribeSynchronousTo, ISubscribeSynchronousTo, - ISubscribeSynchronousTo + ISubscribeSynchronousTo, + ISubscribeSynchronousTo { public async Task HandleAsync( IDomainEvent domainEvent, @@ -98,15 +99,24 @@ await ptsHelper.IncrementPtsAsync(domainEvent.AggregateEvent.PeerId, domainEvent newUnreadCount: -domainEvent.AggregateEvent.ReadCount); } - public async Task HandleAsync( - IDomainEvent domainEvent, - CancellationToken cancellationToken) - { - foreach (var item in domainEvent.AggregateEvent.MessageItems) - { - await ptsHelper.IncrementPtsAsync(item.OwnerPeer.PeerId, item.Pts); - } - } + public async Task HandleAsync( + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + foreach (var item in domainEvent.AggregateEvent.MessageItems) + { + await ptsHelper.IncrementPtsAsync(item.OwnerPeer.PeerId, item.Pts); + } + } + + public Task HandleAsync( + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + return ptsHelper.IncrementPtsAsync(domainEvent.AggregateEvent.UserId, + domainEvent.AggregateEvent.Pts, + domainEvent.AggregateEvent.PtsCount); + } public Task HandleAsync( IDomainEvent domainEvent, diff --git a/source/src/MyTelegram.Messenger.QueryServer/DomainEventHandlers/ReadMentionsDomainEventHandler.cs b/source/src/MyTelegram.Messenger.QueryServer/DomainEventHandlers/ReadMentionsDomainEventHandler.cs new file mode 100644 index 000000000..1ade91a72 --- /dev/null +++ b/source/src/MyTelegram.Messenger.QueryServer/DomainEventHandlers/ReadMentionsDomainEventHandler.cs @@ -0,0 +1,29 @@ +namespace MyTelegram.Messenger.QueryServer.DomainEventHandlers; + +public class ReadMentionsDomainEventHandler( + IObjectMessageSender objectMessageSender, + ICommandBus commandBus, + IIdGenerator idGenerator, + IAckCacheService ackCacheService, + IPushDataFactory pushDataFactory) + : DomainEventHandlerBase(objectMessageSender, commandBus, idGenerator, ackCacheService, pushDataFactory), + ISubscribeSynchronousTo +{ + public async Task HandleAsync( + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var affectedHistory = new TAffectedHistory + { + Pts = domainEvent.AggregateEvent.Pts, + PtsCount = domainEvent.AggregateEvent.PtsCount, + Offset = 0 + }; + + await SendRpcMessageToClientAsync( + domainEvent.AggregateEvent.RequestInfo, + affectedHistory, + domainEvent.AggregateEvent.UserId, + domainEvent.AggregateEvent.Pts); + } +} diff --git a/source/src/MyTelegram.Messenger/Handlers/LatestLayer/Messages/GetMessagesViewsHandler.cs b/source/src/MyTelegram.Messenger/Handlers/LatestLayer/Messages/GetMessagesViewsHandler.cs index 87a0e41bb..0ddbeb2f9 100644 --- a/source/src/MyTelegram.Messenger/Handlers/LatestLayer/Messages/GetMessagesViewsHandler.cs +++ b/source/src/MyTelegram.Messenger/Handlers/LatestLayer/Messages/GetMessagesViewsHandler.cs @@ -1,4 +1,7 @@ -namespace MyTelegram.Messenger.Handlers.LatestLayer.Messages; +using IMessageViews = MyTelegram.Schema.Messages.IMessageViews; +using TMessageViews = MyTelegram.Schema.Messages.TMessageViews; + +namespace MyTelegram.Messenger.Handlers.LatestLayer.Messages; /// /// Get and increase the view counter of a message sent or forwarded from a channel @@ -26,9 +29,8 @@ internal sealed class GetMessagesViewsHandler( var peer = peerHelper.GetPeer(obj.Peer, input.UserId); if (peer.PeerType == PeerType.Channel) { - if (obj.Id.Max() < 0) - { - return new MyTelegram.Schema.Messages.TMessageViews + if (obj.Id.Max() <= 0) + return new TMessageViews { Views = [.. obj.Id.Select(p => new Schema.TMessageViews { Views = 1 }) .ToList()], @@ -51,7 +53,7 @@ internal sealed class GetMessagesViewsHandler( var messages = await queryProcessor .ProcessAsync(new GetMessagesByIdListQuery(boxIdList)); var dict = messages.ToDictionary(k => k.MessageId, v => v); - return new MyTelegram.Schema.Messages.TMessageViews + return new TMessageViews { Chats = [], Users = [], @@ -66,4 +68,4 @@ internal sealed class GetMessagesViewsHandler( })] }; } -} +} \ No newline at end of file diff --git a/source/src/MyTelegram.Messenger/Handlers/LatestLayer/Messages/ReadMentionsHandler.cs b/source/src/MyTelegram.Messenger/Handlers/LatestLayer/Messages/ReadMentionsHandler.cs index 54719c08d..5430c7063 100644 --- a/source/src/MyTelegram.Messenger/Handlers/LatestLayer/Messages/ReadMentionsHandler.cs +++ b/source/src/MyTelegram.Messenger/Handlers/LatestLayer/Messages/ReadMentionsHandler.cs @@ -10,11 +10,36 @@ /// 400 PEER_ID_INVALID The provided peer id is invalid. /// See /// -internal sealed class ReadMentionsHandler : RpcResultObjectHandler +internal sealed class ReadMentionsHandler( + ICommandBus commandBus, + IPeerHelper peerHelper, + IAccessHashHelper accessHashHelper, + IQueryProcessor queryProcessor, + IPtsHelper ptsHelper) + : RpcResultObjectHandler { - protected override Task HandleCoreAsync(IRequestInput input, + protected override async Task HandleCoreAsync(IRequestInput input, MyTelegram.Schema.Messages.RequestReadMentions obj) { - throw new NotImplementedException(); + await accessHashHelper.CheckAccessHashAsync(input, obj.Peer); + var peer = peerHelper.GetPeer(obj.Peer, input.UserId); + var dialogId = DialogId.Create(input.UserId, peer); + + var dialogReadModel = await queryProcessor.ProcessAsync(new GetDialogByIdQuery(dialogId.Value)); + + if (dialogReadModel != null && dialogReadModel.UnreadMentionsCount > 0) + { + var messageId = obj.TopMsgId ?? dialogReadModel.TopMessage; + var command = new ReadMentionCommand(dialogId, input.ToRequestInfo(), input.UserId, messageId, true); + await commandBus.PublishAsync(command); + return null!; + } + + return new TAffectedHistory + { + Pts = ptsHelper.GetCachedPts(input.UserId), + PtsCount = 0, + Offset = 0 + }; } } diff --git a/source/src/MyTelegram.Messenger/Handlers/LatestLayer/Messages/SendMessageHandler.cs b/source/src/MyTelegram.Messenger/Handlers/LatestLayer/Messages/SendMessageHandler.cs index b7797b5ac..bc4769d5b 100644 --- a/source/src/MyTelegram.Messenger/Handlers/LatestLayer/Messages/SendMessageHandler.cs +++ b/source/src/MyTelegram.Messenger/Handlers/LatestLayer/Messages/SendMessageHandler.cs @@ -72,25 +72,35 @@ internal sealed class SendMessageHandler( : RpcResultObjectHandler { protected override async Task HandleCoreAsync(IRequestInput input, - RequestSendMessage obj) + RequestSendMessage obj) { await accessHashHelper.CheckAccessHashAsync(input, obj.Peer); await accessHashHelper.CheckAccessHashAsync(input, obj.SendAs); - var media = await ProcessUrlsInMessageAsync(obj); + + var media = await messageAppService.CreateInvitePreviewIfAnyAsync( + obj.Message, + options.Value.JoinChatDomain); + if (obj.Message.StartsWith("/")) { - obj.Entities ??= []; - obj.Entities.Add(new TMessageEntityBotCommand + var match = Regex.Match(obj.Message, @"^/([A-Za-z0-9_]{1,64})\b"); + + if (match.Success) { - Length = obj.Message.Length, - Offset = 0 - }); + obj.Entities ??= []; + obj.Entities.Add(new TMessageEntityBotCommand + { + Offset = 0, + Length = match.Length // includes leading slash + }); + } } int? topMsgId = null; var sendAs = peerHelper.GetPeer(obj.SendAs, input.UserId); - var sendMessageInput = new SendMessageInput(input.ToRequestInfo(), + var sendMessageInput = new SendMessageInput( + input.ToRequestInfo(), input.UserId, peerHelper.GetPeer(obj.Peer, input.UserId), obj.Message, @@ -112,49 +122,4 @@ protected override async Task HandleCoreAsync(IRequestInput input, return null!; } - private async Task ProcessUrlsInMessageAsync(RequestSendMessage obj) - { - var pattern = @"(?:^|\s)(https?://[^\s]+)(?=\s|$)"; - var pattern2 = @$"{options.Value.JoinChatDomain}/\+([\S]{{16}})"; - var matches = Regex.Matches(obj.Message, pattern); - var isInviteUrlAdded = false; - TMessageMediaWebPage? media = null; - foreach (Match match in matches) - { - obj.Entities ??= []; - var url = match.Groups[1].Value; - var m2 = Regex.Match(url, pattern2); - if (m2.Success && !isInviteUrlAdded) - { - var link = m2.Groups[1].Value; - var chatInvite = await queryProcessor.ProcessAsync(new GetChatInviteByLinkQuery(link)); - if (chatInvite != null) - { - var channelReadModel = await channelAppService.GetAsync(chatInvite.PeerId); - // Super group/Public channel - if (!channelReadModel.Broadcast || - (channelReadModel.Broadcast && !string.IsNullOrEmpty(channelReadModel.UserName))) - { - media = new TMessageMediaWebPage - { - Webpage = new Schema.TWebPage - { - Id = Random.Shared.NextInt64(), - Url = $"{options.Value.JoinChatDomain}/+{link}", - DisplayUrl = $"{options.Value.JoinChatDomain}/+{link}", - Type = channelReadModel.Broadcast ? "telegram_channel" : "telegram_megagroup", - SiteName = "MyTelegram", - Title = channelReadModel.Title, - Description = $"Join this group on MyTelegram.", - } - }; - } - - isInviteUrlAdded = true; - } - } - } - - return media; - } } diff --git a/source/src/MyTelegram.Messenger/NativeAot/MyMessengerJsonContext.g.cs b/source/src/MyTelegram.Messenger/NativeAot/MyMessengerJsonContext.g.cs index 21de9e5ca..d76ab6e23 100644 --- a/source/src/MyTelegram.Messenger/NativeAot/MyMessengerJsonContext.g.cs +++ b/source/src/MyTelegram.Messenger/NativeAot/MyMessengerJsonContext.g.cs @@ -258,9 +258,10 @@ namespace MyTelegram.Messenger.NativeAot; [JsonSerializable(typeof(MyTelegram.Domain.Sagas.MessageUnpinnedSagaEvent))] [JsonSerializable(typeof(MyTelegram.Domain.Sagas.PinChannelMessagePtsIncrementedSagaEvent))] [JsonSerializable(typeof(MyTelegram.Domain.Sagas.PostChannelIdUpdatedSagaEvent))] -[JsonSerializable(typeof(MyTelegram.Domain.Sagas.ReadHistoryPtsIncrementedSagaEvent))] -[JsonSerializable(typeof(MyTelegram.Domain.Sagas.ReadHistoryStartedSagaEvent))] -[JsonSerializable(typeof(MyTelegram.Domain.Sagas.ReplyBroadcastChannelCompletedSagaEvent))] +[JsonSerializable(typeof(MyTelegram.Domain.Sagas.ReadHistoryPtsIncrementedSagaEvent))] +[JsonSerializable(typeof(MyTelegram.Domain.Sagas.ReadHistoryStartedSagaEvent))] +[JsonSerializable(typeof(MyTelegram.Domain.Sagas.ReadMentionsCompletedSagaEvent))] +[JsonSerializable(typeof(MyTelegram.Domain.Sagas.ReplyBroadcastChannelCompletedSagaEvent))] [JsonSerializable(typeof(MyTelegram.Domain.Sagas.SetDiscussionGroupSagaStartedSagaEvent))] [JsonSerializable(typeof(MyTelegram.Domain.Sagas.UnpinAllMessagesCompletedSagaEvent))] [JsonSerializable(typeof(MyTelegram.Domain.Sagas.UnpinAllMessagesStartedSagaEvent))] diff --git a/source/src/MyTelegram.Messenger/NativeAot/MySagaAggregateStore.cs b/source/src/MyTelegram.Messenger/NativeAot/MySagaAggregateStore.cs index 386a024ed..53a0e7dda 100644 --- a/source/src/MyTelegram.Messenger/NativeAot/MySagaAggregateStore.cs +++ b/source/src/MyTelegram.Messenger/NativeAot/MySagaAggregateStore.cs @@ -137,13 +137,16 @@ private async Task> UpdateInternalAsync(ISagaI case ReadChannelHistorySagaId readChannelHistorySagaId: domainEvents = await aggregateStore.UpdateAsync(readChannelHistorySagaId, sourceId, updateSaga, cancellationToken); break; - case ReadHistorySagaId readHistorySagaId: - domainEvents = await aggregateStore.UpdateAsync(readHistorySagaId, sourceId, updateSaga, cancellationToken); - break; - - case SignInSagaId signInSagaId: - domainEvents = await aggregateStore.UpdateAsync(signInSagaId, sourceId, updateSaga, cancellationToken); - break; + case ReadHistorySagaId readHistorySagaId: + domainEvents = await aggregateStore.UpdateAsync(readHistorySagaId, sourceId, updateSaga, cancellationToken); + break; + case ReadMentionsSagaId readMentionsSagaId: + domainEvents = await aggregateStore.UpdateAsync(readMentionsSagaId, sourceId, updateSaga, cancellationToken); + break; + + case SignInSagaId signInSagaId: + domainEvents = await aggregateStore.UpdateAsync(signInSagaId, sourceId, updateSaga, cancellationToken); + break; case UpdateContactProfilePhotoSagaId updateContactProfilePhotoSagaId: domainEvents = await aggregateStore.UpdateAsync(updateContactProfilePhotoSagaId, sourceId, updateSaga, cancellationToken); break; diff --git a/source/src/MyTelegram.Messenger/Services/Impl/MessageAppService.cs b/source/src/MyTelegram.Messenger/Services/Impl/MessageAppService.cs index 6f8ddf33a..18adca038 100644 --- a/source/src/MyTelegram.Messenger/Services/Impl/MessageAppService.cs +++ b/source/src/MyTelegram.Messenger/Services/Impl/MessageAppService.cs @@ -1,4 +1,6 @@ -namespace MyTelegram.Messenger.Services.Impl; +using TWebPage = MyTelegram.Schema.TWebPage; + +namespace MyTelegram.Messenger.Services.Impl; public class MessageAppService( IQueryProcessor queryProcessor, @@ -10,19 +12,74 @@ public class MessageAppService( IUserAppService userAppService, IPrivacyAppService privacyAppService, IContactAppService contactAppService, + IUsernameHelper usernameHelper, IOffsetHelper offsetHelper, IIdGenerator idGenerator) : BaseAppService, IMessageAppService, ITransientDependency { - private const string HashtagPattern = "#(\\w+)"; - private const string UrlPattern = @"(?:^|\s)((https?:\/\/)?[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(\/[^\s,.:;!?]*)?)"; + private const string HashtagPattern = @"#[A-Za-z][A-Za-z0-9_]{0,255}"; + + // Max length clamp for URL entities + private const int MaxUrlLength = 2048; + + // Use a short timeout to prevent runaway backtracking + private static readonly TimeSpan RxTimeout = TimeSpan.FromMilliseconds(150); + + // Cap for normal letter TLDs: e.g., "com", "technology", "international" (<=15) + private const int TldMaxLetters = 15; + + // Strong URL regex: optional scheme, IPv4 or domain, optional port, path allowing balanced parens; + // no trailing punctuation inside the entity; avoids picking up emails/usernames. + private static readonly Regex UrlRegex = new( + """ + (?xi) + (?\d{2,5}) )? # optional port + + (?: # --- optional path/query/frag --- + / # path starts + (?: + [^\s<>()\[\]{}"'`]+ + | \([^\s<>()\[\]{}"'`]*\) + )* + )? + (?= + \s | $ | [)\]\}.,!?;:] # stop before trailing punctuation/space/end + ) + """, + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, + RxTimeout); + + // Email ranges we want to exclude from mentions + private static readonly Regex EmailRegex = new( + @"(?xi)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,24}\b", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase, + RxTimeout); public void CheckBotPermission(long requestUserId, Peer toPeer) { if (peerHelper.IsBotUser(requestUserId) && peerHelper.IsBotUser(toPeer.PeerId)) - { RpcErrors.RpcErrors400.UserIsBot.ThrowRpcError(); - } } public async Task CanSendAsPeerAsync(long channelId, long userId) @@ -35,20 +92,14 @@ public async Task CanSendAsPeerAsync(long channelId, long userId) { var channelAdmin = channelReadModel.AdminList.FirstOrDefault(p => p.UserId == userId); if (channelReadModel.CreatorId == userId || (channelAdmin?.AdminRights.PostMessages ?? false)) - { canSendAsPeer = true; - } } if (!canSendAsPeer) - { // Super group with linked channel/Public super group if (channelReadModel.MegaGroup && (!string.IsNullOrEmpty(channelReadModel.UserName) || channelReadModel.LinkedChatId != null)) - { canSendAsPeer = true; - } - } return canSendAsPeer; } @@ -57,28 +108,19 @@ public async Task IsValidSendAsPeerAsync(long requestUserId, Peer toPeer, { if (sendAsPeer != null) { - if (toPeer.PeerType != PeerType.Channel) - { - return false; - } + if (toPeer.PeerType != PeerType.Channel) return false; switch (sendAsPeer.PeerType) { case PeerType.User: case PeerType.Self: - if (sendAsPeer.PeerId != requestUserId) - { - return false; - } + if (sendAsPeer.PeerId != requestUserId) return false; break; case PeerType.Channel: var canSendAsPeer = await CanSendAsPeerAsync(toPeer.PeerId, requestUserId); - if (!canSendAsPeer) - { - return false; - } + if (!canSendAsPeer) return false; var sendAsChannelReadModel = await channelAppService.GetAsync(sendAsPeer.PeerId); @@ -88,9 +130,7 @@ public async Task IsValidSendAsPeerAsync(long requestUserId, Peer toPeer, (string.IsNullOrEmpty(sendAsChannelReadModel.UserName) && sendAsChannelReadModel.LinkedChatId != toPeer.PeerId && sendAsChannelReadModel.ChannelId != toPeer.PeerId)) - { return false; - } break; } @@ -102,10 +142,7 @@ public async Task IsValidSendAsPeerAsync(long requestUserId, Peer toPeer, public async Task CheckSendAsAsync(long requestUserId, Peer toPeer, Peer? sendAsPeer) { var isValid = await IsValidSendAsPeerAsync(requestUserId, toPeer, sendAsPeer); - if (!isValid) - { - RpcErrors.RpcErrors400.SendAsPeerInvalid.ThrowRpcError(); - } + if (!isValid) RpcErrors.RpcErrors400.SendAsPeerInvalid.ThrowRpcError(); } public async Task GetChannelDifferenceAsync(GetDifferenceInput input) @@ -160,12 +197,10 @@ public Task SearchGlobalAsync(SearchGlobalInput input) { return GetMessagesCoreAsync(input); } + public async Task SendMessageAsync(List inputs) { - if (inputs.Count == 0) - { - throw new ArgumentException(); - } + if (inputs.Count == 0) throw new ArgumentException(); List sendMessageItems = []; var firstInput = inputs.First(); @@ -204,31 +239,24 @@ public async Task SearchPostsAsync(long selfUserId, SearchPos new GetChannelMemberListByChannelIdListQuery(selfUserId, channelIds.ToList())); var photoReadModels = await photoAppService.GetPhotosAsync(channelReadModels); - return new SearchPostsResult(messageReadModels, channelReadModels, channelMemberReadModels, photoReadModels, userReadModels); + return new SearchPostsResult(messageReadModels, channelReadModels, channelMemberReadModels, photoReadModels, + userReadModels); } private async Task CheckChannelBannedRightsAsync(SendMessageInput input) { - if (input.ToPeer.PeerType != PeerType.Channel) - { - return null; - } + if (input.ToPeer.PeerType != PeerType.Channel) return null; var channelReadModel = await channelAppService.GetAsync(input.ToPeer.PeerId); if (channelReadModel!.Broadcast) { var admin = channelReadModel.AdminList.FirstOrDefault(p => p.UserId == input.SenderUserId); if (admin == null || !admin.AdminRights.PostMessages) - { RpcErrors.RpcErrors403.ChatWriteForbidden.ThrowRpcError(); - } } var bannedDefaultRights = channelReadModel.DefaultBannedRights ?? ChatBannedRights.CreateDefaultBannedRights(); - if (bannedDefaultRights.SendMessages) - { - RpcErrors.RpcErrors403.ChatWriteForbidden.ThrowRpcError(); - } + if (bannedDefaultRights.SendMessages) RpcErrors.RpcErrors403.ChatWriteForbidden.ThrowRpcError(); var channelMemberReadModel = await queryProcessor.ProcessAsync(new GetChannelMemberByUserIdQuery(channelReadModel.ChannelId, @@ -239,7 +267,6 @@ await queryProcessor.ProcessAsync(new GetChannelMemberByUserIdQuery(channelReadM { if (channelReadModel is { Broadcast: false, LinkedChatId: not null, JoinToSend: false }) { - } else { @@ -252,20 +279,12 @@ await queryProcessor.ProcessAsync(new GetChannelMemberByUserIdQuery(channelReadM var memberBannedRights = ChatBannedRights.FromValue(channelMemberReadModel.BannedRights, channelMemberReadModel.UntilDate); if (!string.IsNullOrEmpty(input.Message)) - { if (memberBannedRights.SendMessages) - { RpcErrors.RpcErrors400.UserBannedInChannel.ThrowRpcError(); - } - } if (input.Media != null) - { if (memberBannedRights.SendMedia) - { RpcErrors.RpcErrors400.UserBannedInChannel.ThrowRpcError(); - } - } } //if (channelReadModel.SlowModeEnabled) @@ -284,10 +303,7 @@ await queryProcessor.ProcessAsync(new GetChannelMemberByUserIdQuery(channelReadM // 3.If the client does not pass a value, the default SendAsPeer is not set, and in the discussion group, use discussion group as SendAsPeer if (input.SendAs != null) { - if (await IsValidSendAsPeerAsync(input.RequestInfo.UserId, input.ToPeer, input.SendAs)) - { - return input.SendAs; - } + if (await IsValidSendAsPeerAsync(input.RequestInfo.UserId, input.ToPeer, input.SendAs)) return input.SendAs; } else if (input.ToPeer.PeerType == PeerType.Channel) { @@ -296,36 +312,27 @@ await queryProcessor.ProcessAsync(new GetChannelMemberByUserIdQuery(channelReadM if (!await CanSendAsPeerAsync(input.ToPeer.PeerId, input.RequestInfo.UserId)) { var admin = channelReadModel.AdminList.FirstOrDefault(p => p.UserId == input.SenderUserId); - if (admin is { AdminRights.Anonymous: true }) - { - return channelReadModel.ChannelId.ToChannelPeer(); - } + if (admin is { AdminRights.Anonymous: true }) return channelReadModel.ChannelId.ToChannelPeer(); return null; } + Peer? sendAsPeer; var userConfigReadModel = await queryProcessor.ProcessAsync( new GetUserConfigByKeyQuery(input.RequestInfo.UserId, ((int)UserConfigType.SendAsPeer).ToString())); if (userConfigReadModel != null) - { if (long.TryParse(userConfigReadModel.Value, out var sendAsPeerId)) { sendAsPeer = peerHelper.GetPeer(sendAsPeerId); if (await IsValidSendAsPeerAsync(input.RequestInfo.UserId, input.ToPeer, sendAsPeer)) - { return sendAsPeer; - } } - } if (channelReadModel is { MegaGroup: true, LinkedChatId: not null }) { sendAsPeer = channelReadModel.ChannelId.ToChannelPeer(); - if (await IsValidSendAsPeerAsync(input.RequestInfo.UserId, input.ToPeer, sendAsPeer)) - { - return sendAsPeer; - } + if (await IsValidSendAsPeerAsync(input.RequestInfo.UserId, input.ToPeer, sendAsPeer)) return sendAsPeer; } } @@ -341,10 +348,7 @@ private async Task CreateSendMessageItemAsync(SendMessageInput var entities = input.Entities ?? []; var mentionedUserIds = await ProcessMessageEntitiesAsync(input.Message, entities, input.ToPeer); - if (entities.Count == 0) - { - entities = null; - } + if (entities.Count == 0) entities = null; var ownerPeerId = input.ToPeer.PeerType == PeerType.Channel ? input.ToPeer.PeerId : input.SenderUserId; var replyToMsgId = input.InputReplyTo.ToReplyToMsgId(); @@ -363,10 +367,7 @@ await queryProcessor.ProcessAsync(new GetReplyToMsgIdListQuery(input.ToPeer, inp string? postAuthor = null; var isPublicPost = channelReadModel is { Broadcast: true, UserName: not null }; int? views = null; - if (channelReadModel?.Broadcast ?? false) - { - views = 1; - } + if (channelReadModel?.Broadcast ?? false) views = 0; if (channelReadModel is { Signatures: true, Broadcast: true }) { if (sendAs?.PeerType == PeerType.Channel) @@ -380,10 +381,7 @@ await queryProcessor.ProcessAsync(new GetReplyToMsgIdListQuery(input.ToPeer, inp postAuthor = $"{userReadModel.FirstName} {userReadModel.LastName}"; } - if (sendAs == null && channelReadModel.SignatureProfiles) - { - sendAs = input.RequestInfo.UserId.ToUserPeer(); - } + if (sendAs == null && channelReadModel.SignatureProfiles) sendAs = input.RequestInfo.UserId.ToUserPeer(); } var scheduleDate = input.ScheduleDate; @@ -392,29 +390,20 @@ await queryProcessor.ProcessAsync(new GetReplyToMsgIdListQuery(input.ToPeer, inp // If the schedule_date is less than 20 seconds in the future, the message will be sent immediately, // generating a normal updateNewMessage/updateNewChannelMessage. if (scheduleDate.Value - CurrentDate < 20) - { scheduleDate = null; - } else - { idType = IdType.ScheduleMessageId; - } } var pts = 0; MessageReply? reply = null; - if (post && linkedChannelId.HasValue) - { - reply = new MessageReply(linkedChannelId, 0, 0, 0, []); - } + if (post && linkedChannelId.HasValue) reply = new MessageReply(linkedChannelId, 0, 0, 0, []); var messageId = await idGenerator.NextIdAsync(idType, ownerPeerId); //var messageId = 0; int? scheduleMessageId = null; if (idType == IdType.ScheduleMessageId) - { scheduleMessageId = await idGenerator.NextIdAsync(IdType.ScheduleMessageId, ownerPeerId); - } var date = CurrentDate; var hashtags = GetHashtags(input.Message); @@ -467,26 +456,17 @@ await queryProcessor.ProcessAsync(new GetReplyToMsgIdListQuery(input.ToPeer, inp public List GetHashtags(string? message) { - if (string.IsNullOrEmpty(message)) - { - return []; - } + if (string.IsNullOrEmpty(message)) return []; var matches = Regex.Matches(message, HashtagPattern); var hashtags = new List(); const int maxHashtags = 10; foreach (Match match in matches) { - if (hashtags.Count > maxHashtags) - { - break; - } + if (hashtags.Count > maxHashtags) break; var hashtag = match.Groups[1].Value; - if (!hashtags.Contains(hashtag)) - { - hashtags.Add(hashtag); - } + if (!hashtags.Contains(hashtag)) hashtags.Add(hashtag); } return hashtags; @@ -505,9 +485,7 @@ private async Task CheckGlobalPrivacySettingsAsync(SendMessageInput input) var contactType = await contactAppService.GetContactTypeAsync(input.RequestInfo.UserId, input.ToPeer.PeerId); if (contactType != ContactType.Mutual && contactType != ContactType.ContactOfTargetUser) - { RpcErrors.RpcErrors406.PrivacyPremiumRequired.ThrowRpcError(); - } } } } @@ -515,117 +493,24 @@ private async Task CheckGlobalPrivacySettingsAsync(SendMessageInput input) public Task> ProcessMessageEntitiesAsync(string? message, IList? entities, Peer toPeer) { - if (string.IsNullOrEmpty(message)) - { + if (string.IsNullOrWhiteSpace(message)) return Task.FromResult>([]); - } - ProcessMessageEntityHashtag(message, entities); - ProcessMessageEntityUrlList(message, entities); - return ProcessMessageEntityMentionAsync(message, entities, toPeer); - } + // 1) URLs first (also returns overlap guard map) + var used = ProcessMessageEntityUrlListWithOverlap(message, ref entities); - private async Task> ProcessMessageEntityMentionAsync(string message, IList? entities, Peer toPeer) - { - var mentionsAndUserNames = GetMentions(message); - var mentions = mentionsAndUserNames.mentions; - var mentionedUserNames = mentionsAndUserNames.userNameList; - var mentionedUserIds = new List(); - - if (entities?.Count > 0) - { - foreach (var messageEntity in entities) - { - switch (messageEntity) - { - case TInputMessageEntityMentionName inputMessageEntityMentionName: - var userPeer = peerHelper.GetPeer(inputMessageEntityMentionName.UserId); - mentionedUserIds.Add(userPeer.PeerId); - break; - case TMessageEntityMention messageEntityMention: - mentionedUserNames.Add(message.Substring(messageEntityMention.Offset + 1, - messageEntityMention.Length - 1)); - break; - case TMessageEntityMentionName messageEntityMentionName: - mentionedUserIds.Add(messageEntityMentionName.UserId); - break; - } - } - } - - if (mentionedUserNames.Count > 0) - { - entities ??= []; - foreach (var messageEntityMention in mentions) - { - entities.Add(messageEntityMention); - } - } - - if (toPeer.PeerType == PeerType.Channel) - { - var mentionedUsers = - await queryProcessor.ProcessAsync(new GetUserNameListByNamesQuery(mentionedUserNames, PeerType.User)); - mentionedUserIds.AddRange(mentionedUsers.Select(p => p.PeerId).Distinct().ToList()); - - var memberUserIds = - await queryProcessor.ProcessAsync(new GetChannelMemberIdListQuery(toPeer.PeerId, mentionedUserIds)); - - mentionedUserIds = memberUserIds.ToList(); - } - else - { - mentionedUserIds = []; - } - - return mentionedUserIds; - } - - private (List mentions, List userNameList) GetMentions(string message) - { - var pattern = "@(\\w{4,40})"; - var mentions = new List(); - var matches = Regex.Matches(message, pattern); - var userNameList = new List(); - foreach (Match match in matches) - { - if (match.Success) - { - mentions.Add(new TMessageEntityMention - { - Offset = match.Index, - Length = match.Length - }); - userNameList.Add(match.Value[1..]); - } - } + // 2) Hashtags (no overlap concerns in your spec, but we can keep as-is) + ProcessMessageEntityHashtag(message, entities); - return (mentions, userNameList); - } - - private void ProcessMessageEntityUrlList(string message, IList? entities) - { - var matches = Regex.Matches(message, UrlPattern); - foreach (Match match in matches) - { - if (match.Success) - { - var entity = new TMessageEntityUrl - { - Offset = match.Index, - Length = match.Length - }; - entities ??= []; - entities.Add(entity); - } - } + // 3) Mentions (skip any overlap with URLs/emails) + var result = ProcessMessageEntityMentionAsyncSafe(message, entities, toPeer, used); + return result; } private void ProcessMessageEntityHashtag(string message, IList? entities) { var hashtagMatches = Regex.Matches(message, HashtagPattern); foreach (Match match in hashtagMatches) - { if (match.Success) { var entity = new TMessageEntityHashtag @@ -636,7 +521,6 @@ private void ProcessMessageEntityHashtag(string message, IList? entities ??= []; entities.Add(entity); } - } } private Task GetMessagesCoreAsync(TRequest input) @@ -654,8 +538,8 @@ private async Task GetMessagesInternalAsync(GetMessagesQuery q IReadOnlyCollection? chats = null) { var messageList = await queryProcessor.ProcessAsync(query); - HashSet userIds = users?.ToHashSet() ?? []; - HashSet channelIds = chats?.ToHashSet() ?? []; + var userIds = users?.ToHashSet() ?? []; + var channelIds = chats?.ToHashSet() ?? []; userIds.Add(query.SelfUserId); AddExtraPeerIds(messageList, userIds, channelIds); @@ -682,25 +566,18 @@ private async Task GetMessagesInternalAsync(GetMessagesQuery q IReadOnlyCollection joinedChannelIdList = new List(); if (channelIds.Count > 0) - { joinedChannelIdList = await queryProcessor .ProcessAsync(new GetJoinedChannelIdListQuery(query.SelfUserId, [.. channelIds])); - } var privacyList = await privacyAppService.GetPrivacyListAsync(userIdList); IReadOnlyCollection channelMemberList = new List(); if (joinedChannelIdList.Count > 0) - { channelMemberList = await queryProcessor .ProcessAsync( new GetChannelMemberListByChannelIdListQuery(query.SelfUserId, joinedChannelIdList.ToList())); - } var pts = query.Pts; - if (pts == 0 && messageList.Count > 0) - { - pts = messageList.Max(p => p.Pts); - } + if (pts == 0 && messageList.Count > 0) pts = messageList.Max(p => p.Pts); var pollIdList = messageList.Where(p => p.PollId.HasValue).Select(p => p.PollId!.Value).ToList(); IReadOnlyCollection? pollReadModels = null; @@ -788,10 +665,7 @@ void AddPeerIdIfNeeded(Peer? peer) switch (messageReadModel.MessageAction) { case TMessageActionChatAddUser messageActionChatAddUser: - foreach (var userId in messageActionChatAddUser.Users) - { - userIds.Add(userId); - } + foreach (var userId in messageActionChatAddUser.Users) userIds.Add(userId); break; case TMessageActionChatJoinedByLink messageActionChatJoinedByLink: @@ -808,4 +682,251 @@ void AddPeerIdIfNeeded(Peer? peer) } } } + + // Creates URL entities, respecting max length and "one entity per character" rule. + // Returns a boolean mask of used characters (to prevent overlaps with mentions). + private static bool[] ProcessMessageEntityUrlListWithOverlap(string message, ref IList? entities) + { + var used = new bool[message.Length]; + var matches = UrlRegex.Matches(message); + if (matches.Count == 0) return used; + + entities ??= []; + + foreach (Match m in matches) + { + var (start, length) = TrimTrailingPunctuationAndBalance(message, m.Index, m.Length); + if (length <= 0) continue; + + if (length > MaxUrlLength) + length = MaxUrlLength; + + if (AnyUsed(used, start, length)) + continue; + + MarkUsed(used, start, length); + + entities.Add(new TMessageEntityUrl + { + Offset = start, + Length = length + }); + } + + return used; + } + + private static (int start, int length) TrimTrailingPunctuationAndBalance(string s, int start, int length) + { + if (length <= 0) return (start, 0); + + while (length > 0) + { + var ch = s[start + length - 1]; + if (")]},.!?;:".IndexOf(ch) >= 0) length--; + else break; + } + + // Balance trailing ')' if they exceed '(' in the captured piece + int open = 0, close = 0; + for (var i = 0; i < length; i++) + { + var c = s[start + i]; + if (c == '(') open++; + else if (c == ')') close++; + } + + while (length > 0 && close > open) + if (s[start + length - 1] == ')') + { + length--; + close--; + } + else + { + break; + } + + return (start, length); + } + + private static bool AnyUsed(bool[] used, int start, int len) + { + var end = Math.Min(used.Length, start + len); + for (var i = start; i < end; i++) + if (used[i]) + return true; + return false; + } + + private static void MarkUsed(bool[] used, int start, int len) + { + var end = Math.Min(used.Length, start + len); + for (var i = start; i < end; i++) + used[i] = true; + } + + // New mention processor that ignores usernames inside URLs or emails + private async Task> ProcessMessageEntityMentionAsyncSafe( + string message, + IList? entities, + Peer toPeer, + bool[] usedByUrls) + { + // Mark email ranges as used too (so @ inside email never becomes mention) + var used = (bool[])usedByUrls.Clone(); + foreach (Match em in EmailRegex.Matches(message)) + MarkUsed(used, em.Index, em.Length); + + // Collect inline-provided entities first (existing logic preserved) + var mentionedUserIds = new List(); + var candidateUsernames = new List(); + var mentionEntities = new List(); + + if (entities is { Count: > 0 }) + foreach (var e in entities) + switch (e) + { + case TInputMessageEntityMentionName named: + mentionedUserIds.Add(peerHelper.GetPeer(named.UserId).PeerId); + break; + + case TMessageEntityMention m: + // Keep these, but we’ll add text-parsed mentions below (non-overlapping) + candidateUsernames.Add(message.Substring(m.Offset + 1, m.Length - 1)); + mentionEntities.Add(m); + break; + + case TMessageEntityMentionName mn: + mentionedUserIds.Add(mn.UserId); + break; + } + + foreach (var (start, length, uname) in usernameHelper.FindMentions(message)) + { + if (AnyUsed(used, start, length)) + continue; + + MarkUsed(used, start, length); + candidateUsernames.Add(uname); + mentionEntities.Add(new TMessageEntityMention { Offset = start, Length = length }); + } + + if (mentionEntities.Count > 0) + { + entities ??= []; + foreach (var m in mentionEntities) + entities.Add(m); + } + + // Resolve to user IDs (same as your original logic) + if (toPeer.PeerType == PeerType.Channel && candidateUsernames.Count > 0) + { + var mentionedUsers = await queryProcessor.ProcessAsync( + new GetUserNameListByNamesQuery(candidateUsernames, PeerType.User)); + + mentionedUserIds.AddRange(mentionedUsers.Select(p => p.PeerId).Distinct()); + + var memberUserIds = await queryProcessor.ProcessAsync( + new GetChannelMemberIdListQuery(toPeer.PeerId, mentionedUserIds)); + + return memberUserIds.ToList(); + } + + return []; + } + + public async Task CreateInvitePreviewIfAnyAsync( + string text, + string joinChatDomain) + { + if (string.IsNullOrWhiteSpace(text)) + return null; + + var inviteRx = BuildInviteRegex(joinChatDomain); // add the builder below if you don't have it yet + + // Scan URLs once, trim punctuation, clamp length + var matches = UrlRegex.Matches(text); + if (matches.Count == 0) + return null; + + foreach (Match m in matches) + { + var (start, length) = TrimTrailingPunctuationAndBalance(text, m.Index, m.Length); + if (length <= 0) continue; + if (length > MaxUrlLength) length = MaxUrlLength; + + var url = text.Substring(start, length); + var im = inviteRx.Match(url); + if (!im.Success) continue; + + var link = im.Groups["link"].Value; + + var chatInvite = await queryProcessor.ProcessAsync(new GetChatInviteByLinkQuery(link)); + if (chatInvite is null) continue; + + var channel = await channelAppService.GetAsync(chatInvite.PeerId); + + // Supergroup/public channel preview only + if (!channel.Broadcast || (channel.Broadcast && !string.IsNullOrEmpty(channel.UserName))) + { + var baseJoin = joinChatDomain.TrimEnd('/'); + return new TMessageMediaWebPage + { + Webpage = new TWebPage + { + Id = Random.Shared.NextInt64(), + Url = $"{baseJoin}/+{link}", + DisplayUrl = $"{baseJoin}/+{link}", + Type = channel.Broadcast ? "telegram_channel" : "telegram_megagroup", + SiteName = "MyTelegram", + Title = channel.Title, + Description = "Join this group on MyTelegram." + } + }; + } + + // Only one preview is allowed; if this one is not eligible, continue scanning. + } + + return null; + } + + private Regex BuildInviteRegex(string joinDomain) + { + var normalized = joinDomain.Trim().TrimEnd('/'); + string host, path = ""; + try + { + if (normalized.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + var u = new Uri(normalized); + host = u.Host; + path = u.AbsolutePath.Trim('/'); + } + else + { + var slash = normalized.IndexOf('/'); + host = slash >= 0 ? normalized[..slash] : normalized; + path = slash >= 0 ? normalized[(slash + 1)..] : ""; + } + } + catch + { + host = normalized; + } + + var hostRx = Regex.Escape(host); + var pathRx = string.IsNullOrEmpty(path) ? "" : $"{Regex.Escape(path)}/"; + + var pat = $$""" + (?xi) + \b + (?:https?://)? (?:www\.)? {{hostRx}} / {{pathRx}} \+ (?[A-Za-z0-9_-]{16,64}) + (?= \s | $ | [)\]\}.,!?;:] ) + """; + + return new Regex(pat, RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, + RxTimeout); + } } \ No newline at end of file diff --git a/source/src/MyTelegram.Messenger/Services/Impl/UsernameHelper.cs b/source/src/MyTelegram.Messenger/Services/Impl/UsernameHelper.cs index b547798c0..2eb14966c 100644 --- a/source/src/MyTelegram.Messenger/Services/Impl/UsernameHelper.cs +++ b/source/src/MyTelegram.Messenger/Services/Impl/UsernameHelper.cs @@ -2,9 +2,79 @@ public class UsernameHelper : IUsernameHelper, ITransientDependency { - private static readonly string Pattern = "^[a-z0-9_]{5,32}$"; - public bool IsValidUsername(string username) + private static readonly TimeSpan RxTimeout = TimeSpan.FromMilliseconds(150); + + // Validation for actual usernames (Telegram-like) + private static readonly Regex UsernameValidation = new( + @"^(?=.{5,32}$)[a-z0-9_]+$", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase, + RxTimeout); + + // Same URL + email regexes as service (kept private here for exclusion) + private static readonly Regex UrlRegex = new( + """ + (?xi) + (?()\[\]{}"'`]+|\([^\s<>()\[\]{}"'`]*\))*)? + (?=\s|$|[)\]\}.,!?;:]) + """, + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase, + RxTimeout); + + private static readonly Regex EmailRegex = new( + @"(?xi)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,24}\b", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase, + RxTimeout); + + // Mention detection with email/TLD safeguard; final exclusion via overlap below + private static readonly Regex MentionRegex = new( + @"(?xi)(? UsernameValidation.IsMatch(username ?? string.Empty); + + public IEnumerable<(int Offset, int Length, string Username)> FindMentions(string text) { - return Regex.IsMatch(username, Pattern, RegexOptions.IgnoreCase); + if (string.IsNullOrEmpty(text)) + yield break; + + var used = new bool[text.Length]; + + // Mark URL + email spans as excluded + foreach (Match m in UrlRegex.Matches(text)) + Mark(used, m.Index, m.Length); + foreach (Match m in EmailRegex.Matches(text)) + Mark(used, m.Index, m.Length); + + foreach (Match m in MentionRegex.Matches(text)) + { + int start = m.Index, len = m.Length; + if (Overlaps(used, start, len)) + continue; + + var uname = m.Groups[1].Value; + yield return (start, len, uname); + } + + static void Mark(bool[] a, int s, int l) + { + int e = Math.Min(a.Length, s + l); + for (int i = Math.Max(0, s); i < e; i++) a[i] = true; + } + + static bool Overlaps(bool[] a, int s, int l) + { + int e = Math.Min(a.Length, s + l); + for (int i = Math.Max(0, s); i < e; i++) + if (a[i]) return true; + return false; + } } -} \ No newline at end of file +} diff --git a/source/src/MyTelegram.Messenger/Services/Interfaces/IChannelMessageViewsAppService.cs b/source/src/MyTelegram.Messenger/Services/Interfaces/IChannelMessageViewsAppService.cs index 58710045e..1d49f8c1b 100644 --- a/source/src/MyTelegram.Messenger/Services/Interfaces/IChannelMessageViewsAppService.cs +++ b/source/src/MyTelegram.Messenger/Services/Interfaces/IChannelMessageViewsAppService.cs @@ -1,21 +1,21 @@ -using IMessageViews = MyTelegram.Schema.IMessageViews; - -namespace MyTelegram.Messenger.Services.Interfaces; - -public interface IChannelMessageViewsAppService -{ - Task IncrementViewsIfNotIncrementedAsync(long selfUserId, - long authKeyId, - long channelId, - int messageId); - void IncrementViews(long selfUserId, long channelId, int messageId); - - Task> GetMessageViewsAsync(long selfUserId, - long authKeyId, - long channelId, - List messageIdList); - - void SaveViewsFilters(); - void LoadViewsFilters(); - void RotateDaily(); +using IMessageViews = MyTelegram.Schema.IMessageViews; + +namespace MyTelegram.Messenger.Services.Interfaces; + +public interface IChannelMessageViewsAppService +{ + Task IncrementViewsIfNotIncrementedAsync(long selfUserId, + long authKeyId, + long channelId, + int messageId); + void IncrementViews(long selfUserId, long channelId, int messageId); + + Task> GetMessageViewsAsync(long selfUserId, + long authKeyId, + long channelId, + List messageIdList); + + void SaveViewsFilters(); + void LoadViewsFilters(); + void RotateDaily(); } \ No newline at end of file diff --git a/source/src/MyTelegram.Messenger/Services/Interfaces/IMessageAppService.cs b/source/src/MyTelegram.Messenger/Services/Interfaces/IMessageAppService.cs index f3b8a06b7..f4db82f21 100644 --- a/source/src/MyTelegram.Messenger/Services/Interfaces/IMessageAppService.cs +++ b/source/src/MyTelegram.Messenger/Services/Interfaces/IMessageAppService.cs @@ -23,4 +23,7 @@ public interface IMessageAppService Task> ProcessMessageEntitiesAsync(string? message, IList? entities, Peer toPeer); List GetHashtags(string? message); Task IsValidSendAsPeerAsync(long requestUserId, Peer toPeer, Peer? sendAsPeer); + Task CreateInvitePreviewIfAnyAsync( + string text, + string joinChatDomain); } \ No newline at end of file diff --git a/source/src/MyTelegram.Messenger/Services/Interfaces/IUsernameHelper.cs b/source/src/MyTelegram.Messenger/Services/Interfaces/IUsernameHelper.cs index f31ef49c7..29e442648 100644 --- a/source/src/MyTelegram.Messenger/Services/Interfaces/IUsernameHelper.cs +++ b/source/src/MyTelegram.Messenger/Services/Interfaces/IUsernameHelper.cs @@ -3,4 +3,5 @@ public interface IUsernameHelper { bool IsValidUsername(string username); + IEnumerable<(int Offset, int Length, string Username)> FindMentions(string text); } \ No newline at end of file diff --git a/source/src/MyTelegram.ReadModel/Impl/DialogReadModel.cs b/source/src/MyTelegram.ReadModel/Impl/DialogReadModel.cs index 7397f3c00..c001e6e99 100644 --- a/source/src/MyTelegram.ReadModel/Impl/DialogReadModel.cs +++ b/source/src/MyTelegram.ReadModel/Impl/DialogReadModel.cs @@ -23,7 +23,9 @@ public class DialogReadModel : IDialogReadModel, IAmReadModelFor, IAmReadModelFor, IAmReadModelFor, - IAmReadModelFor + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor { public virtual int ChannelHistoryMinId { get; private set; } public virtual DateTime CreationTime { get; private set; } @@ -369,4 +371,20 @@ public Task ApplyAsync(IReadModelContext context, IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + UnreadMentionsCount = domainEvent.AggregateEvent.UnreadMentionsCount; + return Task.CompletedTask; + } + + public Task ApplyAsync(IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + UnreadMentionsCount = domainEvent.AggregateEvent.UnreadMentionsCount; + return Task.CompletedTask; + } } diff --git a/source/src/MyTelegram.ReadModel/Impl/MessageReadModel.cs b/source/src/MyTelegram.ReadModel/Impl/MessageReadModel.cs index 22ab605e3..0245785e5 100644 --- a/source/src/MyTelegram.ReadModel/Impl/MessageReadModel.cs +++ b/source/src/MyTelegram.ReadModel/Impl/MessageReadModel.cs @@ -23,7 +23,8 @@ public class MessageReadModel : IMessageReadModel, IAmReadModelFor, IAmReadModelFor, IAmReadModelFor, - IAmReadModelFor + IAmReadModelFor, + IAmReadModelFor { public int Date { get; private set; } public int? EditDate { get; private set; } @@ -92,11 +93,11 @@ public class MessageReadModel : IMessageReadModel, public bool InvertMedia { get; private set; } public bool PublicPosts { get; private set; } public List Hashtags { get; private set; } = []; - public List? MentionedUserIds { get; private set; } - public long? TodoId { get; private set; } - - public Task ApplyAsync(IReadModelContext context, - IDomainEvent domainEvent, + public List? MentionedUserIds { get; private set; } + public long? TodoId { get; private set; } + + public Task ApplyAsync(IReadModelContext context, + IDomainEvent domainEvent, CancellationToken cancellationToken) { var messageItem = domainEvent.AggregateEvent.OutboxMessageItem; @@ -216,10 +217,19 @@ public Task ApplyAsync(IReadModelContext context, ExpirationTime = messageItem.Date + messageItem.TtlPeriod.Value; } - InvertMedia = messageItem.InvertMedia; - - return Task.CompletedTask; - } + InvertMedia = messageItem.InvertMedia; + + return Task.CompletedTask; + } + + public Task ApplyAsync(IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + Views = domainEvent.AggregateEvent.Views; + + return Task.CompletedTask; + } public Task ApplyAsync(IReadModelContext context, IDomainEvent domainEvent,