using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using QMax.Api.Contracts; using QMax.Api.Data; using QMax.Api.Data.Entities; using QMax.Api.Infrastructure.Hubs; using QMax.Api.Infrastructure.Max; using QMax.Api.Infrastructure.Storage; using QMax.Api.Services; namespace QMax.Api.Controllers; [ApiController] [Authorize] [Route("api/chats")] public sealed class ChatsController( QMaxDbContext db, ChatProjectionService projection, IMaxBridgeClient maxBridge, IAttachmentStorageService storage, IHubContext hubContext, MaxBridgeSyncService syncService) : ControllerBase { [HttpGet] public async Task>> GetChats(CancellationToken cancellationToken) { var chats = (await db.Chats.ToArrayAsync(cancellationToken)) .OrderByDescending(x => x.IsPinned) .ThenByDescending(x => x.UnreadCount > 0) .ThenByDescending(x => x.LastMessageAt ?? x.UpdatedAt) .Take(200) .ToArray(); return chats.Select(projection.ToDto).ToArray(); } [HttpPost("direct")] public async Task> CreateDirect(CreateDirectChatRequest request, CancellationToken cancellationToken) { var chat = await db.Chats.FirstOrDefaultAsync(x => x.ExternalId == request.ExternalChatId, cancellationToken); if (chat is null) { chat = new Chat { ExternalId = request.ExternalChatId, Title = request.Title, AvatarUrl = request.AvatarUrl, Kind = ChatKind.MaxDialog }; db.Chats.Add(chat); await db.SaveChangesAsync(cancellationToken); } await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); return projection.ToDto(chat); } [HttpGet("{chatId:guid}/messages")] public async Task>> GetMessages(Guid chatId, int take = 80, DateTimeOffset? before = null, CancellationToken cancellationToken = default) { var chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken); if (chat is null) { return NotFound(); } if (before is null && !string.IsNullOrWhiteSpace(chat.ExternalId)) { await syncService.SyncChatHistoryAsync(chat.ExternalId, cancellationToken); db.ChangeTracker.Clear(); chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken); if (chat is null) { return NotFound(); } } if (before is null && chat.UnreadCount > 0) { chat.UnreadCount = 0; await db.SaveChangesAsync(cancellationToken); await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); } var query = db.Messages .Include(x => x.Attachments) .Include(x => x.Reactions) .Where(x => x.ChatId == chatId && x.DeletedAt == null); if (before is not null) { query = query.Where(x => x.SentAt < before); } var messages = (await query.ToArrayAsync(cancellationToken)) .OrderByDescending(x => x.SentAt) .Take(Math.Clamp(take, 1, 200)) .ToArray(); return messages.OrderBy(x => x.SentAt).Select(projection.ToDto).ToArray(); } [HttpPost("{chatId:guid}/read")] public async Task MarkRead(Guid chatId, CancellationToken cancellationToken) { var chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken); if (chat is null) { return NotFound(); } if (chat.UnreadCount > 0) { chat.UnreadCount = 0; chat.UpdatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(cancellationToken); await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); } return NoContent(); } [HttpGet("{chatId:guid}/search")] public async Task>> SearchMessages( Guid chatId, [FromQuery(Name = "q")] string? query, int take = 50, CancellationToken cancellationToken = default) { var term = query?.Trim(); if (string.IsNullOrWhiteSpace(term)) { return BadRequest("Search query is required."); } var chatExists = await db.Chats.AnyAsync(x => x.Id == chatId, cancellationToken); if (!chatExists) { return NotFound(); } var messages = await db.Messages .Include(x => x.Attachments) .Include(x => x.Reactions) .Where(x => x.ChatId == chatId && x.DeletedAt == null) .ToArrayAsync(cancellationToken); var results = messages .Where(x => MatchesSearch(x, term)) .OrderByDescending(x => x.SentAt) .Take(Math.Clamp(take, 1, 200)) .OrderBy(x => x.SentAt) .Select(projection.ToDto) .ToArray(); return results; } [HttpGet("{chatId:guid}/avatar")] public async Task DownloadAvatar(Guid chatId, CancellationToken cancellationToken) { var chat = await db.Chats .AsNoTracking() .FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken); if (chat is null || string.IsNullOrWhiteSpace(chat.AvatarUrl)) { return NotFound(); } var remote = await maxBridge.DownloadMediaAsync(chat.AvatarUrl, cancellationToken); if (remote is null) { return NotFound(); } return File(remote.Stream, remote.ContentType); } [HttpGet("{chatId:guid}/presence")] public async Task> GetPresence(Guid chatId, CancellationToken cancellationToken) { var chat = await db.Chats .AsNoTracking() .FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken); if (chat is null) { return NotFound(); } if (string.IsNullOrWhiteSpace(chat.ExternalId)) { return new ChatPresenceDto(false, null, DateTimeOffset.UtcNow); } var presence = await maxBridge.FetchChatPresenceAsync(chat.ExternalId, cancellationToken); return new ChatPresenceDto( presence?.IsTyping == true, presence?.StatusText, presence?.UpdatedAt ?? DateTimeOffset.UtcNow); } [HttpPost("{chatId:guid}/messages")] public async Task> SendMessage(Guid chatId, SendMessageRequest request, CancellationToken cancellationToken) { var chat = await db.Chats.FindAsync([chatId], cancellationToken); if (chat is null) { return NotFound(); } var text = (request.Text ?? "").Trim(); if (string.IsNullOrWhiteSpace(text)) { return BadRequest("Message text is required."); } if (string.IsNullOrWhiteSpace(chat.ExternalId)) { return BadRequest("Chat is not linked to MAX."); } var result = await maxBridge.SendTextAsync(chat.ExternalId, text, cancellationToken); if (!result.Success) { return MaxSendFailure(result, "text message"); } var message = new Message { ChatId = chat.Id, ExternalId = result.ExternalMessageId, Direction = MessageDirection.Outgoing, Text = text, ReplyToMessageId = request.ReplyToMessageId, SentAt = DateTimeOffset.UtcNow, DeliveryState = MessageDeliveryState.Sent, SenderName = "You" }; db.Messages.Add(message); chat.LastMessagePreview = text; chat.LastMessageAt = message.SentAt; chat.UpdatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(cancellationToken); var dto = projection.ToDto(message); await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken); await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); return dto; } [HttpPatch("{chatId:guid}/messages/{messageId:guid}")] public async Task> EditMessage( Guid chatId, Guid messageId, EditMessageRequest request, CancellationToken cancellationToken) { var text = request.Text.Trim(); if (string.IsNullOrWhiteSpace(text)) { return BadRequest("Message text is required."); } var message = await db.Messages .Include(x => x.Chat) .Include(x => x.Attachments) .Include(x => x.Reactions) .FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken); if (message is null) { return NotFound(); } if (message.Direction != MessageDirection.Outgoing) { return Forbid(); } var maxFailure = await TryApplyMaxActionAsync( message, (externalChatId, externalMessageId) => maxBridge.EditMessageAsync(externalChatId, externalMessageId, message.Text, text, cancellationToken), "edit", cancellationToken); if (maxFailure is not null) { return maxFailure; } message.Text = text; message.EditedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(cancellationToken); if (message.Chat is not null) { await RecalculateChatPreviewAsync(message.Chat, cancellationToken); await db.SaveChangesAsync(cancellationToken); } var dto = projection.ToDto(message); await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageUpdated", dto, cancellationToken); await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); return dto; } [HttpDelete("{chatId:guid}/messages/{messageId:guid}")] public async Task DeleteMessage(Guid chatId, Guid messageId, CancellationToken cancellationToken) { var message = await db.Messages .Include(x => x.Chat) .FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken); if (message is null) { return NotFound(); } var maxFailure = await TryApplyMaxActionAsync( message, (externalChatId, externalMessageId) => maxBridge.DeleteMessageAsync(externalChatId, externalMessageId, message.Text, cancellationToken), "delete", cancellationToken); if (maxFailure is not null) { return maxFailure; } var deletedAt = DateTimeOffset.UtcNow; message.DeletedAt = deletedAt; message.EditedAt = message.EditedAt ?? deletedAt; await db.SaveChangesAsync(cancellationToken); if (message.Chat is not null) { await RecalculateChatPreviewAsync(message.Chat, cancellationToken); await db.SaveChangesAsync(cancellationToken); } await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageDeleted", new MessageDeletedDto(chatId, messageId), cancellationToken); await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); return NoContent(); } [HttpPut("{chatId:guid}/messages/{messageId:guid}/reaction")] public async Task> SetReaction( Guid chatId, Guid messageId, SetReactionRequest request, CancellationToken cancellationToken) { var emoji = NormalizeReaction(request.Emoji); if (emoji is null) { return BadRequest("Reaction is required."); } var message = await db.Messages .Include(x => x.Chat) .Include(x => x.Attachments) .Include(x => x.Reactions) .FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken); if (message is null) { return NotFound(); } var maxFailure = await TryApplyMaxActionAsync( message, (externalChatId, externalMessageId) => maxBridge.SetReactionAsync(externalChatId, externalMessageId, message.Text, emoji, cancellationToken), "reaction", cancellationToken); if (maxFailure is not null) { return maxFailure; } var reaction = message.Reactions.FirstOrDefault(x => x.ActorKey == "self"); if (reaction is null) { reaction = new MessageReaction { MessageId = message.Id, Emoji = emoji, ActorKey = "self", ActorName = "You" }; db.MessageReactions.Add(reaction); } else { reaction.Emoji = emoji; reaction.ActorName = "You"; reaction.CreatedAt = DateTimeOffset.UtcNow; } await db.SaveChangesAsync(cancellationToken); var updated = await LoadMessageForProjectionAsync(chatId, messageId, cancellationToken) ?? message; var dto = projection.ToDto(updated); await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageUpdated", dto, cancellationToken); return dto; } [HttpDelete("{chatId:guid}/messages/{messageId:guid}/reaction")] public async Task> ClearReaction(Guid chatId, Guid messageId, CancellationToken cancellationToken) { var message = await db.Messages .Include(x => x.Chat) .Include(x => x.Attachments) .Include(x => x.Reactions) .FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken); if (message is null) { return NotFound(); } var reaction = message.Reactions.FirstOrDefault(x => x.ActorKey == "self"); if (reaction is not null) { var maxFailure = await TryApplyMaxActionAsync( message, (externalChatId, externalMessageId) => maxBridge.ClearReactionAsync(externalChatId, externalMessageId, message.Text, reaction.Emoji, cancellationToken), "reaction clear", cancellationToken); if (maxFailure is not null) { return maxFailure; } message.Reactions.Remove(reaction); db.MessageReactions.Remove(reaction); await db.SaveChangesAsync(cancellationToken); } var updated = await LoadMessageForProjectionAsync(chatId, messageId, cancellationToken) ?? message; var dto = projection.ToDto(updated); await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageUpdated", dto, cancellationToken); return dto; } [HttpPost("{chatId:guid}/messages/{messageId:guid}/forward")] public async Task> ForwardMessage( Guid chatId, Guid messageId, ForwardMessageRequest request, CancellationToken cancellationToken) { var source = await db.Messages .Include(x => x.Chat) .Include(x => x.Attachments) .FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken); if (source is null) { return NotFound(); } var target = await db.Chats.FindAsync([request.TargetChatId], cancellationToken); if (target is null) { return NotFound(); } if (string.IsNullOrWhiteSpace(target.ExternalId)) { return BadRequest("Target chat is not linked to MAX."); } if (string.IsNullOrWhiteSpace(source.Text) && source.Attachments.Count == 0) { return BadRequest("Cannot forward an empty message."); } var message = new Message { ChatId = target.Id, Direction = MessageDirection.Outgoing, Text = source.Text, ForwardedFrom = ResolveForwardedFrom(source), SentAt = DateTimeOffset.UtcNow, DeliveryState = MessageDeliveryState.Sending, SenderName = "You" }; var sortOrder = 0; foreach (var attachment in source.Attachments.OrderBy(x => x.SortOrder)) { StoredAttachment stored; try { stored = await CopyForwardAttachmentAsync(attachment, cancellationToken); } catch (InvalidOperationException error) { return Problem(error.Message, statusCode: StatusCodes.Status409Conflict); } message.Attachments.Add(new MessageAttachment { OriginalFileName = stored.OriginalFileName, StorageFileName = stored.StorageFileName, ContentType = stored.ContentType, FileSizeBytes = stored.FileSizeBytes, Sha256 = stored.Sha256, Kind = stored.Kind, SortOrder = sortOrder++ }); } var result = await SendForwardedToMaxAsync(target.ExternalId, message, cancellationToken); if (!result.Success) { return MaxSendFailure(result, "forward"); } message.ExternalId = result.ExternalMessageId; message.DeliveryState = MessageDeliveryState.Sent; db.Messages.Add(message); target.LastMessagePreview = ForwardPreview(message); target.LastMessageAt = message.SentAt; target.UpdatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(cancellationToken); var dto = projection.ToDto(message); await hubContext.Clients.Group($"chat:{target.Id}").SendAsync("MessageCreated", dto, cancellationToken); await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); return dto; } [HttpPost("{chatId:guid}/attachments")] [RequestSizeLimit(30L * 1024 * 1024)] public async Task> UploadAttachment( Guid chatId, [FromForm] IFormFile file, [FromForm] string? caption, [FromForm] Guid? replyToMessageId, CancellationToken cancellationToken) { var chat = await db.Chats.FindAsync([chatId], cancellationToken); if (chat is null) { return NotFound(); } if (string.IsNullOrWhiteSpace(chat.ExternalId)) { return BadRequest("Chat is not linked to MAX."); } var stored = await storage.SaveAsync(file, cancellationToken); var result = await maxBridge.SendAttachmentAsync(chat.ExternalId, storage.GetPath(stored.StorageFileName), caption ?? "", cancellationToken); if (!result.Success) { return MaxSendFailure(result, "attachment"); } var message = new Message { ChatId = chat.Id, ExternalId = result.ExternalMessageId, Direction = MessageDirection.Outgoing, Text = caption, ReplyToMessageId = replyToMessageId, SentAt = DateTimeOffset.UtcNow, DeliveryState = MessageDeliveryState.Sent, SenderName = "You", Attachments = [ new MessageAttachment { OriginalFileName = stored.OriginalFileName, StorageFileName = stored.StorageFileName, ContentType = stored.ContentType, FileSizeBytes = stored.FileSizeBytes, Sha256 = stored.Sha256, Kind = stored.Kind } ] }; db.Messages.Add(message); chat.LastMessagePreview = !string.IsNullOrWhiteSpace(caption) ? caption : stored.Kind switch { AttachmentKind.Image => "\u041c\u0435\u0434\u0438\u0430", AttachmentKind.Gif => "\u041c\u0435\u0434\u0438\u0430", AttachmentKind.Video => "\u041c\u0435\u0434\u0438\u0430", AttachmentKind.VoiceNote => "\u0413\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435", AttachmentKind.Sticker => "\u041c\u0435\u0434\u0438\u0430", AttachmentKind.Contact => "\u041a\u043e\u043d\u0442\u0430\u043a\u0442", _ => stored.OriginalFileName }; chat.LastMessageAt = message.SentAt; chat.UpdatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(cancellationToken); var dto = projection.ToDto(message); await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken); await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); return dto; } [HttpGet("{chatId:guid}/attachments/{attachmentId:guid}/content")] public async Task DownloadAttachment(Guid chatId, Guid attachmentId, CancellationToken cancellationToken) { var attachment = await db.MessageAttachments .Include(x => x.Message) .FirstOrDefaultAsync(x => x.Id == attachmentId && x.Message!.ChatId == chatId, cancellationToken); if (attachment is null) { return NotFound(); } var path = storage.GetPath(attachment.StorageFileName); if (!System.IO.File.Exists(path)) { if (string.IsNullOrWhiteSpace(attachment.RemoteUrl)) { return NotFound(); } var remote = await maxBridge.DownloadMediaAsync(attachment.RemoteUrl, cancellationToken); if (remote is null) { return NotFound(); } await using (remote.Stream) { var stored = await storage.SaveRemoteAsync( attachment.OriginalFileName, remote.ContentType, remote.Stream, remote.ContentLength ?? attachment.FileSizeBytes, attachment.Kind, cancellationToken); attachment.OriginalFileName = stored.OriginalFileName; attachment.StorageFileName = stored.StorageFileName; attachment.ContentType = stored.ContentType; attachment.FileSizeBytes = stored.FileSizeBytes; attachment.Sha256 = stored.Sha256; attachment.Kind = stored.Kind; await db.SaveChangesAsync(cancellationToken); path = storage.GetPath(attachment.StorageFileName); } } return PhysicalFile(path, attachment.ContentType, attachment.OriginalFileName, enableRangeProcessing: true); } private ObjectResult MaxSendFailure(MaxSendResult result, string itemName) { return Problem( result.Error ?? $"MAX did not accept the {itemName}.", statusCode: StatusCodes.Status502BadGateway); } private async Task TryApplyMaxActionAsync( Message message, Func> action, string actionName, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); var externalChatId = message.Chat?.ExternalId; var externalMessageId = message.ExternalId; if (string.IsNullOrWhiteSpace(externalChatId) || string.IsNullOrWhiteSpace(externalMessageId)) { return null; } var result = await action(externalChatId, externalMessageId); if (result.Success) { return null; } return Problem( result.Error ?? $"MAX {actionName} action failed.", statusCode: StatusCodes.Status409Conflict); } private static string? NormalizeReaction(string? value) { var emoji = value?.Trim(); return string.IsNullOrWhiteSpace(emoji) || emoji.Length > 16 ? null : emoji; } private async Task LoadMessageForProjectionAsync(Guid chatId, Guid messageId, CancellationToken cancellationToken) { return await db.Messages .AsNoTracking() .Include(x => x.Attachments) .Include(x => x.Reactions) .FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken); } private async Task RecalculateChatPreviewAsync(Chat chat, CancellationToken cancellationToken) { var lastMessage = (await db.Messages .Include(x => x.Attachments) .Where(x => x.ChatId == chat.Id && x.DeletedAt == null) .ToArrayAsync(cancellationToken)) .OrderByDescending(x => x.SentAt) .FirstOrDefault(); chat.LastMessageAt = lastMessage?.SentAt; chat.LastMessagePreview = lastMessage is null ? null : ForwardPreview(lastMessage); chat.UpdatedAt = DateTimeOffset.UtcNow; } private async Task CopyForwardAttachmentAsync(MessageAttachment attachment, CancellationToken cancellationToken) { var path = storage.GetPath(attachment.StorageFileName); if (System.IO.File.Exists(path)) { await using var stream = System.IO.File.OpenRead(path); return await storage.SaveRemoteAsync( attachment.OriginalFileName, attachment.ContentType, stream, attachment.FileSizeBytes, attachment.Kind, cancellationToken); } if (!string.IsNullOrWhiteSpace(attachment.RemoteUrl)) { var remote = await maxBridge.DownloadMediaAsync(attachment.RemoteUrl, cancellationToken); if (remote is not null) { await using (remote.Stream) { return await storage.SaveRemoteAsync( attachment.OriginalFileName, remote.ContentType, remote.Stream, remote.ContentLength ?? attachment.FileSizeBytes, attachment.Kind, cancellationToken); } } } throw new InvalidOperationException($"Cannot forward attachment {attachment.Id}: content is unavailable."); } private async Task SendForwardedToMaxAsync(string externalChatId, Message message, CancellationToken cancellationToken) { var attachments = message.Attachments.OrderBy(x => x.SortOrder).ToArray(); if (attachments.Length == 0) { return string.IsNullOrWhiteSpace(message.Text) ? new MaxSendResult(false, null, "Forwarded message is empty.") : await maxBridge.SendTextAsync(externalChatId, message.Text, cancellationToken); } MaxSendResult? lastResult = null; var errors = new List(); foreach (var attachment in attachments) { var caption = attachment.SortOrder == 0 ? message.Text ?? "" : ""; var result = await maxBridge.SendAttachmentAsync(externalChatId, storage.GetPath(attachment.StorageFileName), caption, cancellationToken); lastResult = result; if (!result.Success && !string.IsNullOrWhiteSpace(result.Error)) { errors.Add(result.Error); } } return errors.Count == 0 ? new MaxSendResult(true, lastResult?.ExternalMessageId, null) : new MaxSendResult(false, lastResult?.ExternalMessageId, string.Join("; ", errors.Distinct())); } private static string? ResolveForwardedFrom(Message source) { return source.SenderName?.Trim().Length > 0 ? source.SenderName.Trim() : source.Chat?.Title; } private static string ForwardPreview(Message message) { if (!string.IsNullOrWhiteSpace(message.Text)) { return message.Text; } var first = message.Attachments.OrderBy(x => x.SortOrder).FirstOrDefault(); return first?.Kind switch { AttachmentKind.Image => "\u041c\u0435\u0434\u0438\u0430", AttachmentKind.Gif => "\u041c\u0435\u0434\u0438\u0430", AttachmentKind.Video => "\u041c\u0435\u0434\u0438\u0430", AttachmentKind.VoiceNote => "\u0413\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435", AttachmentKind.Sticker => "\u041c\u0435\u0434\u0438\u0430", AttachmentKind.Contact => "\u041a\u043e\u043d\u0442\u0430\u043a\u0442", _ => first?.OriginalFileName ?? "\u041f\u0435\u0440\u0435\u0441\u043b\u0430\u043d\u043d\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435" }; } private static bool MatchesSearch(Message message, string term) { return Contains(message.Text, term) || Contains(message.SenderName, term) || Contains(message.ForwardedFrom, term) || message.Attachments.Any(x => Contains(x.OriginalFileName, term) || Contains(x.ContentType, term)) || message.Reactions.Any(x => Contains(x.Emoji, term)); } private static bool Contains(string? value, string term) { return value?.Contains(term, StringComparison.OrdinalIgnoreCase) == true; } }