Files
QMAX/server/QMax.Api/Controllers/ChatsController.cs
T
2026-07-05 13:47:07 +03:00

768 lines
27 KiB
C#

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<QMaxHub> hubContext,
MaxBridgeSyncService syncService) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<IReadOnlyList<ChatDto>>> 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<ActionResult<ChatDto>> 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<ActionResult<IReadOnlyList<MessageDto>>> 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<IActionResult> 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<ActionResult<IReadOnlyList<MessageDto>>> 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<IActionResult> 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<ActionResult<ChatPresenceDto>> 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<ActionResult<MessageDto>> 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 message = new Message
{
ChatId = chat.Id,
Direction = MessageDirection.Outgoing,
Text = text,
ReplyToMessageId = request.ReplyToMessageId,
SentAt = DateTimeOffset.UtcNow,
DeliveryState = MessageDeliveryState.Sending,
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<ActionResult<MessageDto>> 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<IActionResult> 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<ActionResult<MessageDto>> 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<ActionResult<MessageDto>> 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<ActionResult<MessageDto>> 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++
});
}
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<ActionResult<MessageDto>> 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 message = new Message
{
ChatId = chat.Id,
Direction = MessageDirection.Outgoing,
Text = caption,
ReplyToMessageId = replyToMessageId,
SentAt = DateTimeOffset.UtcNow,
DeliveryState = MessageDeliveryState.Sending,
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<IActionResult> 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 async Task<ActionResult?> TryApplyMaxActionAsync(
Message message,
Func<string, string, Task<MaxActionResult>> 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<Message?> 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<StoredAttachment> 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 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;
}
}