Files
IKAR/ikar_server/src/Ikar.Server/Controllers/ChatsController.cs
T

4567 lines
173 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Ikar.Server.Data;
using Ikar.Server.Data.Entities;
using Ikar.Server.Infrastructure;
using Ikar.Server.Infrastructure.Bots;
using Ikar.Server.Infrastructure.Channels;
using Ikar.Server.Infrastructure.Hubs;
using Ikar.Server.Infrastructure.Push;
using Ikar.Server.Infrastructure.Storage;
using Ikar.Shared;
using System.Net.Mime;
using System.Security.Cryptography;
namespace Ikar.Server.Controllers;
[ApiController]
[Authorize]
[Route("api/[controller]")]
public sealed class ChatsController(
IkarDbContext dbContext,
PresenceTracker presenceTracker,
IHubContext<MessengerHub> hubContext,
AttachmentStorageService attachmentStorageService,
PushDispatchQueue pushDispatchQueue,
BotUpdateService botUpdateService,
BotCreatorService botCreatorService) : ControllerBase
{
private const long MaxAttachmentSizeBytes = 25 * 1024 * 1024;
private const long MaxAlbumSizeBytes = 100 * 1024 * 1024;
private const int MaxAlbumAttachments = 10;
private const int DefaultMessagePageSize = 50;
private const int MaxMessagePageSize = 100;
[HttpGet]
public async Task<ActionResult<IReadOnlyList<ChatSummaryDto>>> GetChats(CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var chats = await dbContext.Chats
.Where(x => x.Members.Any(member => member.UserId == userId))
.Include(x => x.Members)
.ThenInclude(x => x.User)
.AsNoTracking()
.AsSplitQuery()
.ToListAsync(cancellationToken);
var chatIds = chats.Select(x => x.Id).ToList();
var latestMessagesByChatId = await LoadLatestMessagesByChatIdAsync(chatIds, cancellationToken);
var unreadCountsByChatId = await LoadUnreadCountsByChatIdAsync(userId, chatIds, cancellationToken);
return Ok(chats
.OrderByDescending(x => x.LastActivityAt ?? x.CreatedAt)
.Select(x => x.ToSummaryDto(
userId,
presenceTracker,
latestMessagesByChatId.GetValueOrDefault(x.Id),
unreadCountsByChatId.GetValueOrDefault(x.Id)))
.ToList());
}
[HttpGet("folders")]
public async Task<ActionResult<IReadOnlyList<ChatFolderDto>>> GetFolders(CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var memberships = await dbContext.ChatMembers
.Where(member => member.UserId == userId)
.Select(member => new
{
member.ChatId,
member.IsArchived,
member.IsPinned,
member.FolderKey,
member.LastReadAt
})
.ToListAsync(cancellationToken);
var chatIds = memberships.Select(x => x.ChatId).ToList();
var unreadCounts = await LoadUnreadCountsByChatIdAsync(userId, chatIds, cancellationToken);
var folders = new List<ChatFolderDto>
{
new("all", "All chats", memberships.Count, unreadCounts.Values.Sum()),
new("unread", "Unread", unreadCounts.Count(x => x.Value > 0), unreadCounts.Values.Sum()),
new("pinned", "Pinned", memberships.Count(x => x.IsPinned), memberships.Where(x => x.IsPinned).Sum(x => unreadCounts.GetValueOrDefault(x.ChatId))),
new("archive", "Archive", memberships.Count(x => x.IsArchived), memberships.Where(x => x.IsArchived).Sum(x => unreadCounts.GetValueOrDefault(x.ChatId)))
};
folders.AddRange(memberships
.Where(x => !string.IsNullOrWhiteSpace(x.FolderKey))
.GroupBy(x => x.FolderKey!)
.OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)
.Select(group => new ChatFolderDto(
group.Key,
group.Key,
group.Count(),
group.Sum(x => unreadCounts.GetValueOrDefault(x.ChatId)))));
return Ok(folders);
}
[HttpGet("public-channels")]
public async Task<ActionResult<IReadOnlyList<ChatSummaryDto>>> GetPublicChannels(
[FromQuery] string? q = null,
[FromQuery] int limit = 50,
CancellationToken cancellationToken = default)
{
var userId = User.GetRequiredUserId();
var channels = await LoadPublicChannelSearchResultsAsync(
q,
cancellationToken,
NormalizeSearchLimit(limit));
return Ok(channels
.Select(chat => chat.ToSummaryDto(userId, presenceTracker))
.ToList());
}
[HttpGet("public/{username}")]
public async Task<ActionResult<ChatDetailsDto>> GetPublicChannelByUsername(
string username,
CancellationToken cancellationToken = default)
{
var userId = User.GetRequiredUserId();
string? publicUsername;
try
{
publicUsername = NormalizePublicUsername(username);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
if (publicUsername is null)
{
return BadRequest("Public username is required.");
}
var chatId = await dbContext.Chats
.AsNoTracking()
.Where(chat =>
chat.Type == ChatType.Channel &&
chat.PublicUsername == publicUsername)
.Select(chat => (Guid?)chat.Id)
.SingleOrDefaultAsync(cancellationToken);
if (chatId is null)
{
return NotFound();
}
var chat = await LoadChatMetadata(
chatId.Value,
userId,
cancellationToken,
asNoTracking: false,
allowPublicChannelPreview: true);
if (chat is null)
{
return NotFound();
}
var page = await LoadMessagesPageAsync(chat, beforeMessageId: null, DefaultMessagePageSize, cancellationToken);
await MarkChannelMessagesViewedAsync(chat, userId, page.Messages, cancellationToken);
var pinnedMessages = await LoadPinnedMessagesAsync(chat, cancellationToken);
return Ok(chat.ToDetailsDto(
userId,
presenceTracker,
messagesOverride: page.Messages,
hasMoreMessages: page.HasMoreMessages,
pinnedMessagesOverride: pinnedMessages));
}
[HttpGet("search")]
public async Task<ActionResult<IReadOnlyList<ChatSearchResultDto>>> Search(
[FromQuery] string q,
[FromQuery] string? filter = null,
[FromQuery] string? type = null,
[FromQuery] int page = 1,
[FromQuery] int limit = 50,
[FromQuery] DateTimeOffset? date = null,
[FromQuery] DateTimeOffset? dateFrom = null,
[FromQuery] DateTimeOffset? dateTo = null,
[FromQuery] Guid? authorId = null,
[FromQuery] string? author = null,
CancellationToken cancellationToken = default)
{
var userId = User.GetRequiredUserId();
var searchPage = await SearchChatsPageAsync(
userId,
q,
BuildSearchFilters(filter, type, date, dateFrom, dateTo, authorId, author),
page,
limit,
cancellationToken);
WriteSearchPageHeaders(searchPage.Page, searchPage.Limit, searchPage.HasMore);
return Ok(searchPage.Results);
}
[HttpGet("search/page")]
public async Task<ActionResult<ChatSearchPageDto>> SearchPage(
[FromQuery] string q,
[FromQuery] string? filter = null,
[FromQuery] string? type = null,
[FromQuery] int page = 1,
[FromQuery] int limit = 50,
[FromQuery] DateTimeOffset? date = null,
[FromQuery] DateTimeOffset? dateFrom = null,
[FromQuery] DateTimeOffset? dateTo = null,
[FromQuery] Guid? authorId = null,
[FromQuery] string? author = null,
CancellationToken cancellationToken = default)
{
var userId = User.GetRequiredUserId();
var searchPage = await SearchChatsPageAsync(
userId,
q,
BuildSearchFilters(filter, type, date, dateFrom, dateTo, authorId, author),
page,
limit,
cancellationToken);
return Ok(searchPage);
}
[HttpGet("{chatId:guid}")]
public async Task<ActionResult<ChatDetailsDto>> GetChat(Guid chatId, CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var chat = await LoadChatMetadata(
chatId,
userId,
cancellationToken,
asNoTracking: false,
allowPublicChannelPreview: true);
if (chat is null)
{
return NotFound();
}
var currentMembership = chat.Members.FirstOrDefault(member => member.UserId == userId);
var previousReadAt = currentMembership?.LastReadAt;
var now = DateTimeOffset.UtcNow;
var firstUnreadMessageId = previousReadAt is null
? null
: await dbContext.Messages
.Where(message =>
message.ChatId == chatId &&
message.SenderId != userId &&
message.DeletedAt == null &&
(message.ExpiresAt == null || message.ExpiresAt > now) &&
message.SentAt > previousReadAt.Value)
.OrderBy(message => message.SentAt)
.Select(message => (Guid?)message.Id)
.FirstOrDefaultAsync(cancellationToken);
var page = await LoadMessagesPageAsync(chat, beforeMessageId: null, DefaultMessagePageSize, cancellationToken);
await MarkChannelMessagesViewedAsync(chat, userId, page.Messages, cancellationToken);
var pinnedMessages = await LoadPinnedMessagesAsync(chat, cancellationToken);
return Ok(chat.ToDetailsDto(
userId,
presenceTracker,
firstUnreadMessageId,
page.Messages,
page.HasMoreMessages,
pinnedMessages));
}
[HttpGet("{chatId:guid}/info")]
public async Task<ActionResult<ChatInfoDto>> GetChatInfo(Guid chatId, CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var chat = await LoadChatMetadata(
chatId,
userId,
cancellationToken,
allowPublicChannelPreview: true);
if (chat is null)
{
return NotFound();
}
var page = await LoadMessagesPageAsync(chat, beforeMessageId: null, DefaultMessagePageSize, cancellationToken);
var pinnedMessages = await LoadPinnedMessagesAsync(chat, cancellationToken);
var details = chat.ToDetailsDto(
userId,
presenceTracker,
messagesOverride: page.Messages,
hasMoreMessages: page.HasMoreMessages,
pinnedMessagesOverride: pinnedMessages);
var now = DateTimeOffset.UtcNow;
var mediaCount = await dbContext.MessageAttachments
.CountAsync(x =>
x.Message.ChatId == chatId &&
x.Message.DeletedAt == null &&
(x.Message.ExpiresAt == null || x.Message.ExpiresAt > now) &&
(x.ContentType.StartsWith("image/") || x.ContentType.StartsWith("video/")),
cancellationToken);
var fileCount = await dbContext.MessageAttachments
.CountAsync(x =>
x.Message.ChatId == chatId &&
x.Message.DeletedAt == null &&
(x.Message.ExpiresAt == null || x.Message.ExpiresAt > now) &&
!x.ContentType.StartsWith("image/") &&
!x.ContentType.StartsWith("video/") &&
!x.ContentType.StartsWith("audio/"),
cancellationToken);
var voiceCount = await dbContext.MessageAttachments
.CountAsync(x =>
x.Message.ChatId == chatId &&
x.Message.DeletedAt == null &&
(x.Message.ExpiresAt == null || x.Message.ExpiresAt > now) &&
x.ContentType.StartsWith("audio/"),
cancellationToken);
var linkCount = await dbContext.Messages
.CountAsync(x =>
x.ChatId == chatId &&
x.DeletedAt == null &&
(x.ExpiresAt == null || x.ExpiresAt > now) &&
(x.Text.Contains("http://") || x.Text.Contains("https://")),
cancellationToken);
var pinnedCount = await dbContext.Messages
.CountAsync(x =>
x.ChatId == chatId &&
x.DeletedAt == null &&
(x.ExpiresAt == null || x.ExpiresAt > now) &&
x.PinnedAt != null,
cancellationToken);
var canInspectInviteLinks = ResolvePermissions(chat, userId).CanInviteUsers;
var inviteLinks = canInspectInviteLinks
? await dbContext.ChatInviteLinks
.AsNoTracking()
.Where(x => x.ChatId == chatId)
.OrderByDescending(x => x.CreatedAt)
.Take(20)
.ToListAsync(cancellationToken)
: [];
var topics = await LoadChatTopicDtosAsync(chatId, cancellationToken);
return Ok(new ChatInfoDto(
details,
mediaCount,
fileCount,
linkCount,
voiceCount,
pinnedCount,
topics.Count,
chat.PublicUsername,
inviteLinks.Select(ToInviteLinkDto).ToList(),
topics,
details.SubscriberCount,
details.AdminCount,
chat.LinkedDiscussionChatId,
chat.ChannelSignaturesEnabled,
details.CanManageChannel,
details.CanPostAsChannel,
canInspectInviteLinks,
chat.Description));
}
[HttpGet("{chatId:guid}/invite-links")]
public async Task<ActionResult<IReadOnlyList<ChatInviteLinkDto>>> GetInviteLinks(Guid chatId, CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var chat = await LoadChatMetadata(chatId, userId, cancellationToken);
if (chat is null)
{
return NotFound();
}
if (!ResolvePermissions(chat, userId).CanInviteUsers)
{
return StatusCode(StatusCodes.Status403Forbidden, "You cannot manage invite links in this chat.");
}
var links = await dbContext.ChatInviteLinks
.AsNoTracking()
.Where(x => x.ChatId == chatId)
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken);
return Ok(links.Select(ToInviteLinkDto).ToList());
}
[HttpPost("{chatId:guid}/invite-links")]
public async Task<ActionResult<ChatInviteLinkDto>> CreateInviteLink(
Guid chatId,
CreateChatInviteLinkRequest request,
CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var chat = await LoadChatMetadata(chatId, userId, cancellationToken, asNoTracking: false);
if (chat is null)
{
return NotFound();
}
if (!ResolvePermissions(chat, userId).CanInviteUsers)
{
return StatusCode(StatusCodes.Status403Forbidden, "You cannot create invite links in this chat.");
}
if (chat.Type == ChatType.Direct)
{
return BadRequest("Direct chats do not support invite links.");
}
DateTimeOffset? expiresAt;
int? maxUses;
int? memberLimit;
try
{
expiresAt = NormalizeInviteExpiresAt(request.ExpiresAt);
maxUses = NormalizeInviteLimit(request.MaxUses, nameof(request.MaxUses));
memberLimit = NormalizeInviteLimit(request.MemberLimit, nameof(request.MemberLimit));
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
var link = new ChatInviteLink
{
ChatId = chatId,
CreatedById = userId,
Token = await GenerateInviteTokenAsync(cancellationToken),
Title = TrimToNull(request.Title, 64),
CreatesJoinRequest = request.CreatesJoinRequest,
ExpiresAt = expiresAt,
MaxUses = maxUses,
MemberLimit = memberLimit,
CreatedAt = DateTimeOffset.UtcNow
};
dbContext.ChatInviteLinks.Add(link);
await dbContext.SaveChangesAsync(cancellationToken);
return Ok(ToInviteLinkDto(link));
}
[HttpDelete("{chatId:guid}/invite-links/{inviteLinkId:guid}")]
public async Task<IActionResult> RevokeInviteLink(Guid chatId, Guid inviteLinkId, CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var chat = await LoadChatMetadata(chatId, userId, cancellationToken);
if (chat is null)
{
return NotFound();
}
if (!ResolvePermissions(chat, userId).CanInviteUsers)
{
return StatusCode(StatusCodes.Status403Forbidden, "You cannot revoke invite links in this chat.");
}
var link = await dbContext.ChatInviteLinks
.SingleOrDefaultAsync(x => x.Id == inviteLinkId && x.ChatId == chatId, cancellationToken);
if (link is null)
{
return NotFound();
}
link.IsRevoked = true;
link.RevokedAt = DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpGet("{chatId:guid}/join-requests")]
public async Task<ActionResult<IReadOnlyList<ChatJoinRequestDto>>> GetJoinRequests(Guid chatId, CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var chat = await LoadChatMetadata(chatId, userId, cancellationToken);
if (chat is null)
{
return NotFound();
}
if (!CanManageMembers(chat, userId))
{
return StatusCode(StatusCodes.Status403Forbidden, "Only admins can review join requests.");
}
var requests = await dbContext.ChatJoinRequests
.AsNoTracking()
.Include(x => x.User)
.ThenInclude(x => x.Sessions)
.Include(x => x.ResolvedByUser)
.ThenInclude(x => x!.Sessions)
.Where(x => x.ChatId == chatId)
.OrderByDescending(x => x.RequestedAt)
.Take(100)
.ToListAsync(cancellationToken);
return Ok(requests.Select(ToJoinRequestDto).ToList());
}
[HttpPost("{chatId:guid}/join-requests/{joinRequestId:guid}")]
public async Task<ActionResult<ChatJoinRequestDto>> ResolveJoinRequest(
Guid chatId,
Guid joinRequestId,
ResolveChatJoinRequestRequest request,
CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var chat = await dbContext.Chats
.Include(x => x.Members)
.ThenInclude(x => x.User)
.SingleOrDefaultAsync(x => x.Id == chatId && x.Members.Any(member => member.UserId == userId), cancellationToken);
if (chat is null)
{
return NotFound();
}
if (!CanManageMembers(chat, userId))
{
return StatusCode(StatusCodes.Status403Forbidden, "Only admins can resolve join requests.");
}
var joinRequest = await dbContext.ChatJoinRequests
.Include(x => x.InviteLink)
.Include(x => x.User)
.ThenInclude(x => x.Sessions)
.Include(x => x.ResolvedByUser)
.ThenInclude(x => x!.Sessions)
.SingleOrDefaultAsync(x => x.Id == joinRequestId && x.ChatId == chatId, cancellationToken);
if (joinRequest is null)
{
return NotFound();
}
if (joinRequest.Status != ChatJoinRequestStatus.Pending)
{
return Ok(ToJoinRequestDto(joinRequest));
}
joinRequest.Status = request.Approve ? ChatJoinRequestStatus.Approved : ChatJoinRequestStatus.Declined;
joinRequest.ResolvedAt = DateTimeOffset.UtcNow;
joinRequest.ResolvedByUserId = userId;
if (request.Approve && chat.Members.All(member => member.UserId != joinRequest.UserId))
{
var availabilityError = ValidateInviteAvailability(joinRequest.InviteLink, DateTimeOffset.UtcNow, chat.Members.Count);
if (availabilityError is not null)
{
return BadRequest(availabilityError);
}
chat.Members.Add(CreateSubscriberMembership(chat, joinRequest.UserId));
joinRequest.InviteLink.UsesCount += 1;
chat.LastActivityAt = DateTimeOffset.UtcNow;
}
await AddModerationLogAsync(
userId,
chatId,
ModerationLogTargetType.JoinRequest,
joinRequest.Id,
request.Approve ? "join_request.approved" : "join_request.declined",
null,
cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
await NotifyChatsChangedAsync(chat.Members.Select(member => member.UserId).Append(joinRequest.UserId), cancellationToken);
return Ok(ToJoinRequestDto(joinRequest));
}
[HttpPost("join/{token}")]
public async Task<ActionResult<JoinChatResultDto>> JoinByInviteLink(string token, CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var normalizedToken = token?.Trim() ?? string.Empty;
var invite = await dbContext.ChatInviteLinks
.Include(x => x.Chat)
.ThenInclude(x => x.Members)
.ThenInclude(x => x.User)
.SingleOrDefaultAsync(x => x.Token == normalizedToken && !x.IsRevoked, cancellationToken);
if (invite is null)
{
return NotFound();
}
if (invite.Chat.Type == ChatType.Direct)
{
return BadRequest("Direct chats do not support invite links.");
}
var isExistingMember = invite.Chat.Members.Any(x => x.UserId == userId);
if (!isExistingMember)
{
var availabilityError = ValidateInviteAvailability(invite, DateTimeOffset.UtcNow, invite.Chat.Members.Count);
if (availabilityError is not null)
{
return BadRequest(availabilityError);
}
}
if (invite.CreatesJoinRequest)
{
if (isExistingMember)
{
var existingChat = await LoadChatWithMessages(invite.ChatId, userId, cancellationToken)
?? throw new InvalidOperationException("Joined chat could not be loaded.");
return Ok(new JoinChatResultDto(
JoinChatResultStatus.Joined,
existingChat.ToDetailsDto(userId, presenceTracker)));
}
var existingRequest = await dbContext.ChatJoinRequests
.SingleOrDefaultAsync(x => x.InviteLinkId == invite.Id && x.UserId == userId, cancellationToken);
if (existingRequest is null)
{
existingRequest = new ChatJoinRequest
{
ChatId = invite.ChatId,
InviteLinkId = invite.Id,
UserId = userId,
RequestedAt = DateTimeOffset.UtcNow
};
dbContext.ChatJoinRequests.Add(existingRequest);
await AddModerationLogAsync(
userId,
invite.ChatId,
ModerationLogTargetType.JoinRequest,
existingRequest.Id,
"join_request.created",
null,
cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
}
return Accepted(new JoinChatResultDto(
JoinChatResultStatus.PendingApproval,
JoinRequestId: existingRequest.Id,
JoinRequestStatus: existingRequest.Status));
}
if (!isExistingMember)
{
invite.Chat.Members.Add(CreateSubscriberMembership(invite.Chat, userId));
invite.UsesCount += 1;
invite.Chat.LastActivityAt = DateTimeOffset.UtcNow;
await AddModerationLogAsync(userId, invite.ChatId, ModerationLogTargetType.InviteLink, invite.Id, "invite_link.joined", null, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
var joined = await LoadChatWithMessages(invite.ChatId, userId, cancellationToken)
?? throw new InvalidOperationException("Joined chat could not be loaded.");
await NotifyChatCreatedAsync(joined, cancellationToken);
return Ok(new JoinChatResultDto(
JoinChatResultStatus.Joined,
joined.ToDetailsDto(userId, presenceTracker)));
}
var loaded = await LoadChatWithMessages(invite.ChatId, userId, cancellationToken)
?? throw new InvalidOperationException("Joined chat could not be loaded.");
return Ok(new JoinChatResultDto(
JoinChatResultStatus.Joined,
loaded.ToDetailsDto(userId, presenceTracker)));
}
[HttpPost("{chatId:guid}/subscribe")]
public async Task<ActionResult<ChatDetailsDto>> SubscribeToChannel(Guid chatId, CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var chat = await dbContext.Chats
.Include(x => x.Members)
.ThenInclude(x => x.User)
.SingleOrDefaultAsync(x => x.Id == chatId && x.Type == ChatType.Channel, cancellationToken);
if (chat is null)
{
return NotFound();
}
if (chat.Members.Any(member => member.UserId == userId))
{
var existing = await LoadChatWithMessages(chatId, userId, cancellationToken)
?? throw new InvalidOperationException("Subscribed channel could not be loaded.");
return Ok(existing.ToDetailsDto(userId, presenceTracker));
}
if (string.IsNullOrWhiteSpace(chat.PublicUsername))
{
return StatusCode(StatusCodes.Status403Forbidden, "Private channels require an invite link.");
}
chat.Members.Add(CreateSubscriberMembership(chat, userId));
chat.LastActivityAt = DateTimeOffset.UtcNow;
await AddModerationLogAsync(userId, chatId, ModerationLogTargetType.User, userId, "channel.subscribed", null, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
var loaded = await LoadChatWithMessages(chatId, userId, cancellationToken)
?? throw new InvalidOperationException("Subscribed channel could not be loaded.");
await NotifyChatCreatedAsync(loaded, cancellationToken);
return Ok(loaded.ToDetailsDto(userId, presenceTracker));
}
[HttpDelete("{chatId:guid}/subscribe")]
public async Task<IActionResult> UnsubscribeFromChannel(Guid chatId, CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var chat = await dbContext.Chats
.Include(x => x.Members)
.SingleOrDefaultAsync(x => x.Id == chatId && x.Type == ChatType.Channel, cancellationToken);
if (chat is null)
{
return NotFound();
}
var membership = chat.Members.FirstOrDefault(member => member.UserId == userId);
if (membership is null)
{
return NoContent();
}
if (membership.IsOwner || membership.Role == ChatMemberRole.Owner)
{
return BadRequest("Transfer channel ownership before leaving the channel.");
}
dbContext.ChatMembers.Remove(membership);
await AddModerationLogAsync(userId, chatId, ModerationLogTargetType.User, userId, "channel.unsubscribed", null, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
await NotifyChatsChangedAsync([userId], cancellationToken);
return NoContent();
}
[HttpGet("{chatId:guid}/topics")]
public async Task<ActionResult<IReadOnlyList<ChatTopicDto>>> GetTopics(Guid chatId, CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var chat = await LoadChatMetadata(chatId, userId, cancellationToken);
if (chat is null)
{
return NotFound();
}
return Ok(await LoadChatTopicDtosAsync(chatId, cancellationToken));
}
[HttpPost("{chatId:guid}/topics")]
public async Task<ActionResult<ChatTopicDto>> CreateTopic(
Guid chatId,
CreateChatTopicRequest request,
CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var chat = await LoadChatMetadata(chatId, userId, cancellationToken, asNoTracking: false);
if (chat is null)
{
return NotFound();
}
if (chat.Type == ChatType.Direct)
{
return BadRequest("Direct chats do not support topics.");
}
if (!CanManageMembers(chat, userId))
{
return StatusCode(StatusCodes.Status403Forbidden, "Only admins can create topics.");
}
var title = request.Title?.Trim() ?? string.Empty;
if (title.Length is < 2 or > 80)
{
return ValidationProblem("Topic title must be between 2 and 80 characters.");
}
var topic = new ChatTopic
{
ChatId = chatId,
CreatedById = userId,
Title = title,
Icon = string.IsNullOrWhiteSpace(request.Icon) ? "💬" : request.Icon.Trim()[..Math.Min(request.Icon.Trim().Length, 8)],
CreatedAt = DateTimeOffset.UtcNow,
LastActivityAt = DateTimeOffset.UtcNow
};
chat.TopicsEnabled = true;
dbContext.ChatTopics.Add(topic);
await dbContext.SaveChangesAsync(cancellationToken);
return Ok(new ChatTopicDto(topic.Id, topic.ChatId, topic.Title, topic.Icon, topic.IsPinned, topic.IsClosed, topic.CreatedAt, topic.LastActivityAt, 0));
}
[HttpGet("{chatId:guid}/messages")]
public async Task<ActionResult<ChatMessagesPageDto>> GetMessages(
Guid chatId,
[FromQuery] Guid? beforeMessageId = null,
[FromQuery] Guid? aroundMessageId = null,
[FromQuery] int limit = DefaultMessagePageSize,
CancellationToken cancellationToken = default)
{
var userId = User.GetRequiredUserId();
var chat = await LoadChatMetadata(chatId, userId, cancellationToken, allowPublicChannelPreview: true);
if (chat is null)
{
return NotFound();
}
var page = aroundMessageId is not null
? await LoadMessagesAroundAsync(chat, aroundMessageId.Value, limit, cancellationToken)
: await LoadMessagesPageAsync(chat, beforeMessageId, limit, cancellationToken);
await MarkChannelMessagesViewedAsync(chat, userId, page.Messages, cancellationToken);
var messages = page.Messages
.Select(message => message.ToDto(presenceTracker, userId))
.ToList();
return Ok(new ChatMessagesPageDto(messages, page.HasMoreMessages));
}
[HttpGet("{chatId:guid}/messages/search")]
public async Task<ActionResult<IReadOnlyList<MessageDto>>> SearchMessages(
Guid chatId,
[FromQuery] string q,
[FromQuery] string? filter = null,
[FromQuery] string? type = null,
[FromQuery] int page = 1,
[FromQuery] int limit = 50,
[FromQuery] DateTimeOffset? date = null,
[FromQuery] DateTimeOffset? dateFrom = null,
[FromQuery] DateTimeOffset? dateTo = null,
[FromQuery] Guid? authorId = null,
[FromQuery] string? author = null,
CancellationToken cancellationToken = default)
{
var userId = User.GetRequiredUserId();
var chat = await LoadChatMetadata(chatId, userId, cancellationToken, allowPublicChannelPreview: true);
if (chat is null)
{
return NotFound();
}
var searchPage = await SearchMessagesPageAsync(
chat,
userId,
q,
BuildSearchFilters(filter, type, date, dateFrom, dateTo, authorId, author),
page,
limit,
cancellationToken);
WriteSearchPageHeaders(searchPage.Page, searchPage.Limit, searchPage.HasMore);
return Ok(searchPage.Messages);
}
[HttpGet("{chatId:guid}/messages/search/page")]
public async Task<ActionResult<MessageSearchPageDto>> SearchMessagesPage(
Guid chatId,
[FromQuery] string q,
[FromQuery] string? filter = null,
[FromQuery] string? type = null,
[FromQuery] int page = 1,
[FromQuery] int limit = 50,
[FromQuery] DateTimeOffset? date = null,
[FromQuery] DateTimeOffset? dateFrom = null,
[FromQuery] DateTimeOffset? dateTo = null,
[FromQuery] Guid? authorId = null,
[FromQuery] string? author = null,
CancellationToken cancellationToken = default)
{
var userId = User.GetRequiredUserId();
var chat = await LoadChatMetadata(chatId, userId, cancellationToken, allowPublicChannelPreview: true);
if (chat is null)
{
return NotFound();
}
return Ok(await SearchMessagesPageAsync(
chat,
userId,
q,
BuildSearchFilters(filter, type, date, dateFrom, dateTo, authorId, author),
page,
limit,
cancellationToken));
}
[HttpPut("{chatId:guid}/state")]
public async Task<ActionResult<ChatSummaryDto>> UpdateChatState(
Guid chatId,
ChatStateRequest request,
CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var chat = await dbContext.Chats
.Include(x => x.Members)
.ThenInclude(x => x.User)
.SingleOrDefaultAsync(
x => x.Id == chatId && x.Members.Any(member => member.UserId == userId),
cancellationToken);
if (chat is null)
{
return NotFound();
}
var membership = chat.Members.First(member => member.UserId == userId);
membership.IsPinned = request.IsPinned ?? membership.IsPinned;
membership.IsArchived = request.IsArchived ?? membership.IsArchived;
membership.MutedUntil = request.IsMuted switch
{
false => null,
true => request.MutedUntil ?? DateTimeOffset.UtcNow.AddYears(1),
_ => request.MutedUntil ?? membership.MutedUntil
};
if (request.FolderKey is not null)
{
membership.FolderKey = NormalizeFolderKey(request.FolderKey);
}
var notifyAllMembers = false;
if (request.Title is not null ||
request.Description is not null ||
request.ClearDescription ||
request.PublicUsername is not null ||
request.ClearPublicUsername)
{
if (chat.Type == ChatType.Direct)
{
return BadRequest("Direct chats do not support profile editing.");
}
if (!CanManageMembers(chat, userId))
{
return StatusCode(StatusCodes.Status403Forbidden, "Only admins can update chat profile.");
}
if (request.Title is not null)
{
var title = request.Title.Trim();
if (string.IsNullOrWhiteSpace(title))
{
return BadRequest("Chat title is required.");
}
chat.Title = title.Length <= 120 ? title : title[..120];
}
if (request.ClearDescription && request.Description is not null)
{
return BadRequest("Description cannot be set and cleared in the same request.");
}
if (request.ClearDescription)
{
chat.Description = null;
}
else if (request.Description is not null)
{
chat.Description = TrimToNull(request.Description, 255);
}
if (request.ClearPublicUsername && request.PublicUsername is not null)
{
return BadRequest("Public username cannot be set and cleared in the same request.");
}
if (request.ClearPublicUsername || request.PublicUsername is not null)
{
if (chat.Type != ChatType.Channel)
{
return BadRequest("Only channels support public usernames.");
}
string? publicUsername;
try
{
publicUsername = request.ClearPublicUsername
? null
: NormalizePublicUsername(request.PublicUsername);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
if (publicUsername is not null)
{
var usernameTaken = await dbContext.Chats
.AnyAsync(x => x.Id != chatId && x.PublicUsername == publicUsername, cancellationToken);
if (usernameTaken)
{
return Conflict("Public username is already taken.");
}
}
chat.PublicUsername = publicUsername;
}
notifyAllMembers = true;
await AddModerationLogAsync(
userId,
chatId,
ModerationLogTargetType.Chat,
chatId,
"chat.profile.updated",
null,
cancellationToken);
}
if (request.HasProtectedContent is not null || request.DefaultMessageTtlSeconds is not null)
{
if (!CanManageMembers(chat, userId))
{
return StatusCode(StatusCodes.Status403Forbidden, "Only admins can update chat security settings.");
}
chat.HasProtectedContent = request.HasProtectedContent ?? chat.HasProtectedContent;
if (request.DefaultMessageTtlSeconds is not null)
{
chat.DefaultMessageTtlSeconds = NormalizeTtlSeconds(request.DefaultMessageTtlSeconds, allowNull: true);
}
await AddModerationLogAsync(
userId,
chatId,
ModerationLogTargetType.Chat,
chatId,
"chat.security.updated",
null,
cancellationToken);
}
if (request.ChannelSignaturesEnabled is not null ||
request.LinkedDiscussionChatId is not null ||
request.ClearLinkedDiscussionChat)
{
if (chat.Type != ChatType.Channel)
{
return BadRequest("Only channels support channel settings.");
}
if (!CanManageMembers(chat, userId))
{
return StatusCode(StatusCodes.Status403Forbidden, "Only admins can update channel settings.");
}
if (request.ChannelSignaturesEnabled is not null)
{
chat.ChannelSignaturesEnabled = request.ChannelSignaturesEnabled.Value;
}
if (request.ClearLinkedDiscussionChat && request.LinkedDiscussionChatId is not null)
{
return BadRequest("Linked discussion chat cannot be set and cleared in the same request.");
}
if (request.ClearLinkedDiscussionChat)
{
chat.LinkedDiscussionChatId = null;
}
else if (request.LinkedDiscussionChatId is not null)
{
var discussionChatExists = await dbContext.Chats
.AsNoTracking()
.AnyAsync(x =>
x.Id == request.LinkedDiscussionChatId.Value &&
x.Type == ChatType.Group &&
x.Members.Any(member => member.UserId == userId),
cancellationToken);
if (!discussionChatExists)
{
return BadRequest("Linked discussion chat must be an accessible group chat.");
}
chat.LinkedDiscussionChatId = request.LinkedDiscussionChatId;
}
await AddModerationLogAsync(
userId,
chatId,
ModerationLogTargetType.Chat,
chatId,
"channel.settings.updated",
null,
cancellationToken);
}
await dbContext.SaveChangesAsync(cancellationToken);
var latestMessages = await LoadLatestMessagesByChatIdAsync([chatId], cancellationToken);
var unreadCounts = await LoadUnreadCountsByChatIdAsync(userId, [chatId], cancellationToken);
await NotifyChatsChangedAsync(notifyAllMembers ? chat.Members.Select(member => member.UserId) : [userId], cancellationToken);
return Ok(chat.ToSummaryDto(
userId,
presenceTracker,
latestMessages.GetValueOrDefault(chatId),
unreadCounts.GetValueOrDefault(chatId)));
}
[HttpPost("{chatId:guid}/members")]
public async Task<ActionResult<ChatDetailsDto>> AddMembers(
Guid chatId,
AddChatMembersRequest request,
CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var requestedMemberIds = (request.MemberIds ?? Array.Empty<Guid>())
.Where(id => id != Guid.Empty)
.Distinct()
.ToList();
if (requestedMemberIds.Count == 0)
{
return BadRequest("At least one member is required.");
}
if (request.Role is not ChatMemberRole.Member and not ChatMemberRole.Admin)
{
return BadRequest("Members can only be added as a member or admin.");
}
var chat = await dbContext.Chats
.Include(x => x.Members)
.ThenInclude(x => x.User)
.SingleOrDefaultAsync(
x => x.Id == chatId && x.Members.Any(member => member.UserId == userId),
cancellationToken);
if (chat is null)
{
return NotFound();
}
if (chat.Type is not ChatType.Group and not ChatType.Channel)
{
return BadRequest("Only groups and channels support adding members with this endpoint.");
}
if (!CanManageMembers(chat, userId))
{
return StatusCode(StatusCodes.Status403Forbidden, "Only admins can add members.");
}
var existingMemberIds = chat.Members
.Select(member => member.UserId)
.ToHashSet();
var newMemberIds = requestedMemberIds
.Where(id => !existingMemberIds.Contains(id))
.ToList();
if (newMemberIds.Count == 0)
{
var existing = await LoadChatWithMessages(chatId, userId, cancellationToken)
?? throw new InvalidOperationException("The chat could not be reloaded.");
var existingPage = await LoadMessagesPageAsync(existing, beforeMessageId: null, DefaultMessagePageSize, cancellationToken);
var existingPinnedMessages = await LoadPinnedMessagesAsync(existing, cancellationToken);
return Ok(existing.ToDetailsDto(
userId,
presenceTracker,
messagesOverride: existingPage.Messages,
hasMoreMessages: existingPage.HasMoreMessages,
pinnedMessagesOverride: existingPinnedMessages));
}
var users = await dbContext.Users
.Where(user => newMemberIds.Contains(user.Id) && !user.IsBlocked)
.Select(user => user.Id)
.ToListAsync(cancellationToken);
if (users.Count != newMemberIds.Count)
{
return BadRequest("Some members do not exist.");
}
foreach (var memberId in newMemberIds)
{
if (await IsBlockedByPrivacyAsync(userId, memberId, cancellationToken) ||
await IsBlockedByPrivacyAsync(memberId, userId, cancellationToken))
{
return StatusCode(StatusCodes.Status403Forbidden, "Adding this member is blocked by privacy settings.");
}
}
var role = request.Role;
var isAdmin = role == ChatMemberRole.Admin;
var defaultCanPostMessages = chat.Type == ChatType.Group && role == ChatMemberRole.Member;
var joinedAt = DateTimeOffset.UtcNow;
foreach (var memberId in newMemberIds)
{
chat.Members.Add(new ChatMember
{
ChatId = chat.Id,
Chat = chat,
UserId = memberId,
Role = role,
IsOwner = false,
CanPinMessages = isAdmin || request.CanPinMessages,
CanDeleteMessages = isAdmin || request.CanDeleteMessages,
CanInviteUsers = isAdmin || request.CanInviteUsers,
CanPostMessages = isAdmin || request.CanPostMessages || defaultCanPostMessages,
JoinedAt = joinedAt
});
await AddModerationLogAsync(
userId,
chatId,
ModerationLogTargetType.User,
memberId,
isAdmin ? "member.admin.added" : "member.added",
null,
cancellationToken);
}
chat.LastActivityAt = DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken);
await NotifyChatsChangedAsync(chat.Members.Select(member => member.UserId).Concat(newMemberIds), cancellationToken);
var loaded = await LoadChatWithMessages(chatId, userId, cancellationToken)
?? throw new InvalidOperationException("The chat could not be reloaded.");
var page = await LoadMessagesPageAsync(loaded, beforeMessageId: null, DefaultMessagePageSize, cancellationToken);
var pinnedMessages = await LoadPinnedMessagesAsync(loaded, cancellationToken);
return Ok(loaded.ToDetailsDto(
userId,
presenceTracker,
messagesOverride: page.Messages,
hasMoreMessages: page.HasMoreMessages,
pinnedMessagesOverride: pinnedMessages));
}
[HttpPut("{chatId:guid}/members/{memberUserId:guid}/permissions")]
public async Task<ActionResult<ChatDetailsDto>> UpdateMemberPermissions(
Guid chatId,
Guid memberUserId,
UpdateChatMemberPermissionsRequest request,
CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var chat = await dbContext.Chats
.Include(x => x.Members)
.ThenInclude(x => x.User)
.SingleOrDefaultAsync(
x => x.Id == chatId &&
x.Members.Any(member => member.UserId == userId) &&
x.Members.Any(member => member.UserId == memberUserId),
cancellationToken);
if (chat is null)
{
return NotFound();
}
if (!CanManageMembers(chat, userId))
{
return StatusCode(StatusCodes.Status403Forbidden, "Only admins can update member permissions.");
}
var target = chat.Members.First(member => member.UserId == memberUserId);
if (target.IsOwner && target.UserId != userId)
{
return BadRequest("Owner permissions cannot be changed by another member.");
}
if (request.Role is not ChatMemberRole.Member and not ChatMemberRole.Admin and not ChatMemberRole.Owner)
{
return ValidationProblem("Unsupported member role.");
}
if (request.Role == ChatMemberRole.Owner && !target.IsOwner)
{
return BadRequest("Ownership can only be transferred by a dedicated owner transfer action.");
}
if (target.IsOwner && request.Role != ChatMemberRole.Owner)
{
return BadRequest("Owner role cannot be changed through member permissions.");
}
target.Role = request.Role;
target.IsOwner = request.Role == ChatMemberRole.Owner;
target.CanPinMessages = request.CanPinMessages;
target.CanDeleteMessages = request.CanDeleteMessages;
target.CanInviteUsers = request.CanInviteUsers;
target.CanPostMessages = request.CanPostMessages;
await AddModerationLogAsync(userId, chatId, ModerationLogTargetType.User, memberUserId, "member.permissions.updated", null, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
await NotifyChatsChangedAsync(chat.Members.Select(member => member.UserId), cancellationToken);
var loaded = await LoadChatWithMessages(chatId, userId, cancellationToken)
?? throw new InvalidOperationException("The chat could not be reloaded.");
var page = await LoadMessagesPageAsync(loaded, beforeMessageId: null, DefaultMessagePageSize, cancellationToken);
var pinnedMessages = await LoadPinnedMessagesAsync(loaded, cancellationToken);
return Ok(loaded.ToDetailsDto(userId, presenceTracker, messagesOverride: page.Messages, hasMoreMessages: page.HasMoreMessages, pinnedMessagesOverride: pinnedMessages));
}
[HttpPost("{chatId:guid}/members/{memberUserId:guid}/transfer-owner")]
public async Task<ActionResult<ChatDetailsDto>> TransferOwnership(
Guid chatId,
Guid memberUserId,
CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var chat = await dbContext.Chats
.Include(x => x.Members)
.ThenInclude(x => x.User)
.SingleOrDefaultAsync(
x => x.Id == chatId &&
x.Members.Any(member => member.UserId == userId) &&
x.Members.Any(member => member.UserId == memberUserId),
cancellationToken);
if (chat is null)
{
return NotFound();
}
if (chat.Type is not ChatType.Group and not ChatType.Channel)
{
return BadRequest("Only groups and channels support ownership transfer.");
}
var current = chat.Members.First(member => member.UserId == userId);
if (!current.IsOwner && current.Role != ChatMemberRole.Owner)
{
return StatusCode(StatusCodes.Status403Forbidden, "Only owner can transfer ownership.");
}
if (memberUserId == userId)
{
return BadRequest("Ownership target must be another member.");
}
var target = chat.Members.First(member => member.UserId == memberUserId);
if (target.User.IsBot)
{
return BadRequest("Bot cannot become chat owner.");
}
foreach (var member in chat.Members)
{
if (member.UserId == memberUserId)
{
member.IsOwner = true;
member.Role = ChatMemberRole.Owner;
member.CanPinMessages = true;
member.CanDeleteMessages = true;
member.CanInviteUsers = true;
member.CanPostMessages = true;
continue;
}
if (member.IsOwner || member.Role == ChatMemberRole.Owner)
{
member.IsOwner = false;
member.Role = ChatMemberRole.Admin;
member.CanPinMessages = true;
member.CanDeleteMessages = true;
member.CanInviteUsers = true;
member.CanPostMessages = true;
}
}
chat.CreatedById = memberUserId;
chat.LastActivityAt = DateTimeOffset.UtcNow;
await AddModerationLogAsync(
userId,
chatId,
ModerationLogTargetType.User,
memberUserId,
"member.owner.transferred",
null,
cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
await NotifyChatsChangedAsync(chat.Members.Select(member => member.UserId), cancellationToken);
var loaded = await LoadChatWithMessages(chatId, userId, cancellationToken)
?? throw new InvalidOperationException("The chat could not be reloaded.");
var page = await LoadMessagesPageAsync(loaded, beforeMessageId: null, DefaultMessagePageSize, cancellationToken);
var pinnedMessages = await LoadPinnedMessagesAsync(loaded, cancellationToken);
return Ok(loaded.ToDetailsDto(
userId,
presenceTracker,
messagesOverride: page.Messages,
hasMoreMessages: page.HasMoreMessages,
pinnedMessagesOverride: pinnedMessages));
}
[HttpDelete("{chatId:guid}/members/{memberUserId:guid}")]
public async Task<ActionResult<ChatDetailsDto>> RemoveMember(
Guid chatId,
Guid memberUserId,
CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var chat = await dbContext.Chats
.Include(x => x.Members)
.ThenInclude(x => x.User)
.SingleOrDefaultAsync(
x => x.Id == chatId &&
x.Members.Any(member => member.UserId == userId) &&
x.Members.Any(member => member.UserId == memberUserId),
cancellationToken);
if (chat is null)
{
return NotFound();
}
if (chat.Type is not ChatType.Group and not ChatType.Channel)
{
return BadRequest("Only groups and channels support removing members.");
}
var current = chat.Members.First(member => member.UserId == userId);
if (!CanManageMembers(chat, userId))
{
return StatusCode(StatusCodes.Status403Forbidden, "Only admins can remove members.");
}
if (memberUserId == userId)
{
return BadRequest("Use leave chat instead of removing yourself.");
}
var target = chat.Members.First(member => member.UserId == memberUserId);
if (target.IsOwner || target.Role == ChatMemberRole.Owner)
{
return BadRequest("Owner cannot be removed.");
}
if (!current.IsOwner && current.Role != ChatMemberRole.Owner && target.Role == ChatMemberRole.Admin)
{
return StatusCode(StatusCodes.Status403Forbidden, "Only owner can remove admins.");
}
dbContext.ChatMembers.Remove(target);
chat.Members.Remove(target);
chat.LastActivityAt = DateTimeOffset.UtcNow;
await AddModerationLogAsync(userId, chatId, ModerationLogTargetType.User, memberUserId, "member.removed", null, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
await NotifyChatsChangedAsync(chat.Members.Select(member => member.UserId).Append(memberUserId), cancellationToken);
var loaded = await LoadChatWithMessages(chatId, userId, cancellationToken)
?? throw new InvalidOperationException("The chat could not be reloaded.");
var page = await LoadMessagesPageAsync(loaded, beforeMessageId: null, DefaultMessagePageSize, cancellationToken);
var pinnedMessages = await LoadPinnedMessagesAsync(loaded, cancellationToken);
return Ok(loaded.ToDetailsDto(
userId,
presenceTracker,
messagesOverride: page.Messages,
hasMoreMessages: page.HasMoreMessages,
pinnedMessagesOverride: pinnedMessages));
}
[HttpDelete("{chatId:guid}")]
public async Task<IActionResult> DeleteChat(
Guid chatId,
[FromQuery] bool forEveryone = false,
CancellationToken cancellationToken = default)
{
var userId = User.GetRequiredUserId();
var chat = await dbContext.Chats
.Include(x => x.Members)
.ThenInclude(x => x.User)
.Include(x => x.Messages)
.ThenInclude(x => x.Attachments)
.SingleOrDefaultAsync(x => x.Id == chatId && x.Members.Any(member => member.UserId == userId), cancellationToken);
if (chat is null)
{
return NotFound();
}
var affectedUserIds = chat.Members
.Select(x => x.UserId)
.ToHashSet();
if (forEveryone)
{
var currentMembership = chat.Members.First(x => x.UserId == userId);
if (chat.Type != ChatType.Direct && !currentMembership.IsOwner)
{
return StatusCode(StatusCodes.Status403Forbidden, "Only chat owners can delete this chat for everyone.");
}
var attachmentPaths = chat.Messages
.SelectMany(x => x.Attachments)
.Select(x => x.StoragePath)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct()
.ToList();
dbContext.Chats.Remove(chat);
await dbContext.SaveChangesAsync(cancellationToken);
DeleteFiles(attachmentPaths);
await NotifyChatsChangedAsync(affectedUserIds, cancellationToken);
return NoContent();
}
var membership = chat.Members.FirstOrDefault(x => x.UserId == userId);
if (membership is null)
{
return NotFound();
}
if (chat.Type is ChatType.Group or ChatType.Channel &&
(membership.IsOwner || membership.Role == ChatMemberRole.Owner))
{
return BadRequest("Owner must transfer ownership or delete the chat for everyone.");
}
dbContext.ChatMembers.Remove(membership);
chat.Members.Remove(membership);
if (chat.Members.Count == 0)
{
var attachmentPaths = chat.Messages
.SelectMany(x => x.Attachments)
.Select(x => x.StoragePath)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct()
.ToList();
dbContext.Chats.Remove(chat);
await dbContext.SaveChangesAsync(cancellationToken);
DeleteFiles(attachmentPaths);
await NotifyChatsChangedAsync(affectedUserIds, cancellationToken);
return NoContent();
}
if (chat.Type == ChatType.Direct && chat.Members.Count == 1)
{
chat.Title = string.IsNullOrWhiteSpace(membership.User.DisplayName)
? membership.User.PhoneNumber ?? "Dialog"
: membership.User.DisplayName;
}
if (chat.CreatedById == userId)
{
var replacementOwner = chat.Members
.OrderByDescending(x => x.IsOwner)
.ThenBy(x => x.JoinedAt)
.First();
replacementOwner.IsOwner = true;
replacementOwner.Role = ChatMemberRole.Owner;
replacementOwner.CanDeleteMessages = true;
replacementOwner.CanPinMessages = true;
replacementOwner.CanInviteUsers = true;
replacementOwner.CanPostMessages = true;
chat.CreatedById = replacementOwner.UserId;
}
if (chat.Type == ChatType.Channel && chat.Members.All(x => !x.IsOwner))
{
var replacementChannelOwner = chat.Members
.OrderBy(x => x.JoinedAt)
.First();
replacementChannelOwner.IsOwner = true;
replacementChannelOwner.Role = ChatMemberRole.Owner;
replacementChannelOwner.CanDeleteMessages = true;
replacementChannelOwner.CanPinMessages = true;
replacementChannelOwner.CanInviteUsers = true;
replacementChannelOwner.CanPostMessages = true;
}
chat.LastActivityAt = chat.Messages
.OrderByDescending(x => x.SentAt)
.Select(x => (DateTimeOffset?)x.SentAt)
.FirstOrDefault() ?? chat.CreatedAt;
await dbContext.SaveChangesAsync(cancellationToken);
await NotifyChatsChangedAsync(affectedUserIds, cancellationToken);
return NoContent();
}
[HttpDelete("{chatId:guid}/history")]
public async Task<IActionResult> ClearChatHistory(Guid chatId, CancellationToken cancellationToken = default)
{
var userId = User.GetRequiredUserId();
var chat = await dbContext.Chats
.Include(x => x.Members)
.ThenInclude(x => x.User)
.Include(x => x.Messages)
.ThenInclude(x => x.Attachments)
.SingleOrDefaultAsync(x => x.Id == chatId && x.Members.Any(member => member.UserId == userId), cancellationToken);
if (chat is null)
{
return NotFound();
}
var hasBotPeer = chat.Type == ChatType.Direct &&
chat.Members.Any(member => member.UserId != userId && member.User.IsBot);
if (!hasBotPeer)
{
return StatusCode(
StatusCodes.Status403Forbidden,
"Chat history can only be cleared in direct chats with bots.");
}
var attachmentPaths = chat.Messages
.SelectMany(x => x.Attachments)
.Select(x => x.StoragePath)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct()
.ToList();
var attachments = chat.Messages
.SelectMany(x => x.Attachments)
.ToList();
var messages = chat.Messages.ToList();
dbContext.MessageAttachments.RemoveRange(attachments);
dbContext.Messages.RemoveRange(messages);
chat.LastActivityAt = chat.CreatedAt;
await dbContext.SaveChangesAsync(cancellationToken);
DeleteFiles(attachmentPaths);
await NotifyChatsChangedAsync(chat.Members.Select(x => x.UserId), cancellationToken);
return NoContent();
}
[HttpPost("direct")]
public async Task<ActionResult<ChatDetailsDto>> CreateDirectChat(CreateDirectChatRequest request, CancellationToken cancellationToken)
{
var currentUserId = User.GetRequiredUserId();
if (request.UserId == currentUserId)
{
return BadRequest("You cannot create a direct chat with yourself.");
}
var otherUserExists = await dbContext.Users.AnyAsync(x => x.Id == request.UserId && !x.IsBlocked, cancellationToken);
if (!otherUserExists)
{
return NotFound("User not found.");
}
if (await IsBlockedByPrivacyAsync(currentUserId, request.UserId, cancellationToken) ||
await IsBlockedByPrivacyAsync(request.UserId, currentUserId, cancellationToken))
{
return StatusCode(StatusCodes.Status403Forbidden, "Direct chats are blocked by privacy settings.");
}
var existingChat = await dbContext.Chats
.Include(x => x.Members)
.ThenInclude(x => x.User)
.Include(x => x.Messages)
.ThenInclude(x => x.Sender)
.Include(x => x.Messages)
.ThenInclude(x => x.Attachments)
.AsSplitQuery()
.SingleOrDefaultAsync(x =>
x.Type == ChatType.Direct &&
x.Members.Count == 2 &&
x.Members.Any(m => m.UserId == currentUserId) &&
x.Members.Any(m => m.UserId == request.UserId),
cancellationToken);
if (existingChat is not null)
{
return Ok(existingChat.ToDetailsDto(currentUserId, presenceTracker));
}
var chat = new Chat
{
Type = ChatType.Direct,
CreatedById = currentUserId,
LastActivityAt = DateTimeOffset.UtcNow,
Members =
[
new ChatMember { UserId = currentUserId, IsOwner = true, Role = ChatMemberRole.Owner, CanDeleteMessages = true },
new ChatMember { UserId = request.UserId, IsOwner = false }
]
};
dbContext.Chats.Add(chat);
await dbContext.SaveChangesAsync(cancellationToken);
var loaded = await LoadChatWithMessages(chat.Id, currentUserId, cancellationToken)
?? throw new InvalidOperationException("Newly created chat could not be loaded.");
await NotifyChatCreatedAsync(loaded, cancellationToken);
return Ok(loaded.ToDetailsDto(currentUserId, presenceTracker));
}
[HttpPost("group")]
public async Task<ActionResult<ChatDetailsDto>> CreateGroupChat(CreateGroupChatRequest request, CancellationToken cancellationToken)
{
var currentUserId = User.GetRequiredUserId();
var title = request.Title?.Trim() ?? string.Empty;
var memberIds = (request.MemberIds ?? Array.Empty<Guid>())
.Append(currentUserId)
.Distinct()
.ToList();
if (string.IsNullOrWhiteSpace(title) || memberIds.Count < 3)
{
return BadRequest("A title and at least 2 invited members are required for a group chat.");
}
var users = await dbContext.Users
.Where(x => memberIds.Contains(x.Id) && !x.IsBlocked)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
if (users.Count != memberIds.Count)
{
return BadRequest("Some group members do not exist.");
}
var chat = new Chat
{
Type = ChatType.Group,
Title = title,
CreatedById = currentUserId,
LastActivityAt = DateTimeOffset.UtcNow,
Members = memberIds
.Select(x => new ChatMember
{
UserId = x,
IsOwner = x == currentUserId,
Role = x == currentUserId ? ChatMemberRole.Owner : ChatMemberRole.Member,
CanDeleteMessages = x == currentUserId
})
.ToList()
};
dbContext.Chats.Add(chat);
await dbContext.SaveChangesAsync(cancellationToken);
var loaded = await LoadChatWithMessages(chat.Id, currentUserId, cancellationToken)
?? throw new InvalidOperationException("Newly created group chat could not be loaded.");
await NotifyChatCreatedAsync(loaded, cancellationToken);
return Ok(loaded.ToDetailsDto(currentUserId, presenceTracker));
}
[HttpPost("channel")]
public async Task<ActionResult<ChatDetailsDto>> CreateChannel(CreateChannelRequest request, CancellationToken cancellationToken)
{
var currentUserId = User.GetRequiredUserId();
var title = request.Title?.Trim() ?? string.Empty;
var description = TrimToNull(request.Description, 255);
var memberIds = (request.MemberIds ?? Array.Empty<Guid>())
.Append(currentUserId)
.Distinct()
.ToList();
string? publicUsername;
if (string.IsNullOrWhiteSpace(title))
{
return BadRequest("Channel title is required.");
}
try
{
publicUsername = NormalizePublicUsername(request.PublicUsername);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
if (publicUsername is not null)
{
var usernameTaken = await dbContext.Chats
.AnyAsync(x => x.PublicUsername == publicUsername, cancellationToken);
if (usernameTaken)
{
return Conflict("Public username is already taken.");
}
}
var users = await dbContext.Users
.Where(x => memberIds.Contains(x.Id) && !x.IsBlocked)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
if (users.Count != memberIds.Count)
{
return BadRequest("Some channel members do not exist.");
}
var chat = new Chat
{
Type = ChatType.Channel,
Title = title,
Description = description,
PublicUsername = publicUsername,
ChannelSignaturesEnabled = request.ChannelSignaturesEnabled,
CreatedById = currentUserId,
LastActivityAt = DateTimeOffset.UtcNow,
Members = memberIds
.Select(x => new ChatMember
{
UserId = x,
IsOwner = x == currentUserId,
Role = x == currentUserId ? ChatMemberRole.Owner : ChatMemberRole.Member,
CanPinMessages = x == currentUserId,
CanDeleteMessages = x == currentUserId,
CanInviteUsers = x == currentUserId,
CanPostMessages = x == currentUserId
})
.ToList()
};
dbContext.Chats.Add(chat);
if (publicUsername is null && request.CreateInviteLink)
{
dbContext.ChatInviteLinks.Add(new ChatInviteLink
{
ChatId = chat.Id,
CreatedById = currentUserId,
Token = await GenerateInviteTokenAsync(cancellationToken),
Title = "Основная ссылка",
CreatedAt = DateTimeOffset.UtcNow
});
}
await dbContext.SaveChangesAsync(cancellationToken);
var loaded = await LoadChatWithMessages(chat.Id, currentUserId, cancellationToken)
?? throw new InvalidOperationException("Newly created channel could not be loaded.");
await NotifyChatCreatedAsync(loaded, cancellationToken);
return Ok(loaded.ToDetailsDto(currentUserId, presenceTracker));
}
[HttpPost("{chatId:guid}/messages")]
public async Task<ActionResult<MessageDto>> SendMessage(Guid chatId, SendMessageRequest request, CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var text = request.Text?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(text))
{
return BadRequest("Message text is required.");
}
var chat = await dbContext.Chats
.Include(x => x.Members)
.FirstOrDefaultAsync(x => x.Id == chatId && x.Members.Any(m => m.UserId == userId), cancellationToken);
if (chat is null)
{
return NotFound();
}
if (!CanUserPublish(chat, userId))
{
return StatusCode(StatusCodes.Status403Forbidden, "Only channel owners can publish.");
}
if (request.ClientMessageId is not null)
{
var existingMessage = await dbContext.Messages
.AsNoTracking()
.SingleOrDefaultAsync(
message => message.SenderId == userId && message.ClientMessageId == request.ClientMessageId,
cancellationToken);
if (existingMessage is not null)
{
if (existingMessage.ChatId != chatId)
{
return Conflict("Client message id is already used in another chat.");
}
var loadedExistingMessage = await LoadMessageForMemberAsync(
chatId,
existingMessage.Id,
userId,
cancellationToken);
if (loadedExistingMessage is null)
{
return NotFound();
}
// Re-enqueueing is safe because the durable outbox deduplicates by message id.
// This repairs the narrow case where the original HTTP request committed the
// message but was interrupted before the push job could be persisted.
await pushDispatchQueue.EnqueueAsync(chatId, existingMessage.Id, cancellationToken);
return Ok(loadedExistingMessage.ToDto(presenceTracker, userId));
}
}
var storyReply = request.StoryReplyStoryId is null
? null
: await LoadVisibleStoryForReplyAsync(request.StoryReplyStoryId.Value, userId, cancellationToken);
if (request.StoryReplyStoryId is not null && storyReply is null)
{
return NotFound("The story was not found.");
}
if (storyReply is not null)
{
if (storyReply.AuthorUserId == userId)
{
return BadRequest("You cannot reply to your own story.");
}
if (chat.Type != ChatType.Direct || chat.Members.All(member => member.UserId != storyReply.AuthorUserId))
{
return BadRequest("Story replies can only be sent to the story author.");
}
}
var replyToMessage = await ResolveReplyTargetAsync(chatId, request.ReplyToMessageId, userId, cancellationToken);
if (request.ReplyToMessageId is not null && replyToMessage is null)
{
return NotFound("The replied message was not found.");
}
if (request.TopicId is not null)
{
var topic = await dbContext.ChatTopics
.SingleOrDefaultAsync(x => x.Id == request.TopicId.Value && x.ChatId == chatId, cancellationToken);
if (topic is null || topic.IsClosed)
{
return NotFound("The topic was not found.");
}
topic.LastActivityAt = DateTimeOffset.UtcNow;
}
var sender = await dbContext.Users.SingleAsync(x => x.Id == userId, cancellationToken);
var ttlSeconds = NormalizeTtlSeconds(request.TtlSeconds ?? chat.DefaultMessageTtlSeconds, allowNull: true);
var sentAt = DateTimeOffset.UtcNow;
var commentedChannelPost = replyToMessage is null
? null
: await LoadCommentedChannelPostAsync(replyToMessage, cancellationToken);
if (commentedChannelPost is not null)
{
commentedChannelPost.CommentCount++;
}
var message = new Message
{
ChatId = chatId,
Chat = chat,
SenderId = userId,
Sender = sender,
ClientMessageId = request.ClientMessageId,
PostedAsChannel = ShouldPostAsChannel(chat),
AuthorSignature = ResolveChannelAuthorSignature(chat, sender),
Text = text,
ReplyToMessageId = replyToMessage?.Id,
ReplyToMessage = replyToMessage,
TopicId = request.TopicId,
HasProtectedContent = request.HasProtectedContent ?? chat.HasProtectedContent,
TtlSeconds = ttlSeconds,
ExpiresAt = ttlSeconds is null ? null : sentAt.AddSeconds(ttlSeconds.Value),
SentAt = sentAt,
StoryReplyStoryId = storyReply?.Id,
StoryReplyAuthorUserId = storyReply?.AuthorUserId,
StoryReplyPreviewText = storyReply is null ? null : BuildStoryReplyPreview(storyReply),
StoryReplyAttachmentKind = storyReply is null ? null : ResolveStoryReplyAttachmentKind(storyReply)
};
chat.LastActivityAt = message.SentAt;
dbContext.Messages.Add(message);
var discussionRootMessage = await ChannelDiscussionMessageFactory.CreateLinkedDiscussionRootAsync(
dbContext,
attachmentStorageService,
chat,
message,
sender,
sentAt,
cancellationToken);
if (discussionRootMessage is not null)
{
message.DiscussionMessageId = discussionRootMessage.Id;
}
pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage);
await dbContext.SaveChangesAsync(cancellationToken);
var loadedMessage = await LoadMessageForMemberAsync(chatId, message.Id, userId, cancellationToken)
?? throw new InvalidOperationException("The created message could not be reloaded.");
var loadedDiscussionRootMessage = discussionRootMessage is null
? null
: await LoadMessageForNotificationAsync(discussionRootMessage, cancellationToken);
var updatedCommentedChannelPost = commentedChannelPost is null
? null
: await LoadMessageForMemberAsync(
commentedChannelPost.ChatId,
commentedChannelPost.Id,
commentedChannelPost.SenderId,
cancellationToken);
var dto = loadedMessage.ToDto(presenceTracker, userId);
await NotifyMessageCreatedAsync(loadedMessage.Chat, loadedMessage, cancellationToken);
await botUpdateService.EnqueueIncomingMessageAsync(loadedMessage.Id, cancellationToken);
await botCreatorService.TryHandleIncomingMessageAsync(loadedMessage.Id, cancellationToken);
await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id);
if (loadedDiscussionRootMessage is not null)
{
await NotifyMessageCreatedAsync(loadedDiscussionRootMessage.Chat, loadedDiscussionRootMessage, cancellationToken);
await botUpdateService.EnqueueIncomingMessageAsync(loadedDiscussionRootMessage.Id, cancellationToken);
await pushDispatchQueue.EnqueueAsync(loadedDiscussionRootMessage.ChatId, loadedDiscussionRootMessage.Id);
}
if (updatedCommentedChannelPost is not null)
{
await NotifyMessageUpdatedAsync(updatedCommentedChannelPost.Chat, [updatedCommentedChannelPost], cancellationToken);
}
return Ok(dto);
}
[HttpPost("{chatId:guid}/messages/{messageId:guid}/forward")]
public async Task<ActionResult<MessageDto>> ForwardMessage(
Guid chatId,
Guid messageId,
ForwardMessageRequest request,
CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var sourceMessage = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken);
if (sourceMessage is null)
{
return NotFound();
}
if (sourceMessage.DeletedAt is not null)
{
return BadRequest("Deleted messages cannot be forwarded.");
}
if (sourceMessage.HasProtectedContent || sourceMessage.Chat.HasProtectedContent)
{
return BadRequest("Protected messages cannot be forwarded.");
}
var targetChat = await dbContext.Chats
.Include(x => x.Members)
.FirstOrDefaultAsync(
x => x.Id == request.TargetChatId &&
x.Members.Any(member => member.UserId == userId),
cancellationToken);
if (targetChat is null)
{
return NotFound();
}
if (!CanUserPublish(targetChat, userId))
{
return StatusCode(StatusCodes.Status403Forbidden, "Only channel owners can publish.");
}
var sender = await dbContext.Users.SingleAsync(x => x.Id == userId, cancellationToken);
var ttlSeconds = NormalizeTtlSeconds(targetChat.DefaultMessageTtlSeconds, allowNull: true);
var sentAt = DateTimeOffset.UtcNow;
var forwardedMessage = new Message
{
ChatId = targetChat.Id,
Chat = targetChat,
SenderId = userId,
Sender = sender,
PostedAsChannel = ShouldPostAsChannel(targetChat),
AuthorSignature = ResolveChannelAuthorSignature(targetChat, sender),
Text = sourceMessage.Text,
ForwardedFromDisplayName = ResolveForwardedFromDisplayName(sourceMessage),
MediaAlbumId = sourceMessage.MediaAlbumId is null ? null : Guid.NewGuid(),
HasProtectedContent = targetChat.HasProtectedContent,
TtlSeconds = ttlSeconds,
ExpiresAt = ttlSeconds is null ? null : sentAt.AddSeconds(ttlSeconds.Value),
SentAt = sentAt
};
var clonedAttachments = new List<MessageAttachment>();
Message? discussionRootMessage = null;
try
{
foreach (var sourceAttachment in sourceMessage.Attachments)
{
var clonedAttachment = await attachmentStorageService.CloneAsync(
forwardedMessage.Id,
sourceAttachment,
cancellationToken);
clonedAttachments.Add(clonedAttachment);
forwardedMessage.Attachments.Add(clonedAttachment);
}
targetChat.LastActivityAt = forwardedMessage.SentAt;
dbContext.Messages.Add(forwardedMessage);
discussionRootMessage = await ChannelDiscussionMessageFactory.CreateLinkedDiscussionRootAsync(
dbContext,
attachmentStorageService,
targetChat,
forwardedMessage,
sender,
sentAt,
cancellationToken);
if (discussionRootMessage is not null)
{
forwardedMessage.DiscussionMessageId = discussionRootMessage.Id;
}
pushDispatchQueue.StageMessages(dbContext, forwardedMessage, discussionRootMessage);
await dbContext.SaveChangesAsync(cancellationToken);
}
catch
{
foreach (var clonedAttachment in clonedAttachments)
{
attachmentStorageService.DeleteIfExists(clonedAttachment.StoragePath);
}
if (discussionRootMessage is not null)
{
DeleteFiles(discussionRootMessage.Attachments.Select(x => x.StoragePath));
}
throw;
}
var loadedForwardedMessage = await LoadMessageForMemberAsync(targetChat.Id, forwardedMessage.Id, userId, cancellationToken)
?? throw new InvalidOperationException("The forwarded message could not be reloaded.");
var loadedDiscussionRootMessage = discussionRootMessage is null
? null
: await LoadMessageForNotificationAsync(discussionRootMessage, cancellationToken);
var dto = loadedForwardedMessage.ToDto(presenceTracker, userId);
await NotifyMessageCreatedAsync(loadedForwardedMessage.Chat, loadedForwardedMessage, cancellationToken);
await botUpdateService.EnqueueIncomingMessageAsync(loadedForwardedMessage.Id, cancellationToken);
await pushDispatchQueue.EnqueueAsync(targetChat.Id, forwardedMessage.Id);
if (loadedDiscussionRootMessage is not null)
{
await NotifyMessageCreatedAsync(loadedDiscussionRootMessage.Chat, loadedDiscussionRootMessage, cancellationToken);
await botUpdateService.EnqueueIncomingMessageAsync(loadedDiscussionRootMessage.Id, cancellationToken);
await pushDispatchQueue.EnqueueAsync(loadedDiscussionRootMessage.ChatId, loadedDiscussionRootMessage.Id);
}
return Ok(dto);
}
[HttpPost("{chatId:guid}/messages/delivered")]
public Task<IActionResult> AcknowledgeDelivered(
Guid chatId,
UpdateMessageReceiptRequest request,
CancellationToken cancellationToken) =>
AcknowledgeMessageReceiptAsync(chatId, request.MessageId, User.GetRequiredUserId(), markRead: false, cancellationToken);
[HttpPost("{chatId:guid}/messages/read")]
public Task<IActionResult> AcknowledgeRead(
Guid chatId,
UpdateMessageReceiptRequest request,
CancellationToken cancellationToken) =>
AcknowledgeMessageReceiptAsync(chatId, request.MessageId, User.GetRequiredUserId(), markRead: true, cancellationToken);
[HttpPost("{chatId:guid}/read")]
public async Task<IActionResult> MarkChatRead(Guid chatId, CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var chat = await dbContext.Chats
.Include(x => x.Members)
.SingleOrDefaultAsync(
x => x.Id == chatId && x.Members.Any(member => member.UserId == userId),
cancellationToken);
if (chat is null)
{
return NotFound();
}
await UpdateReadReceiptForOpenedChatAsync(chat, userId, cancellationToken);
return Ok();
}
[HttpPut("{chatId:guid}/messages/{messageId:guid}/reaction")]
public async Task<ActionResult<MessageDto>> SetReaction(
Guid chatId,
Guid messageId,
SetMessageReactionRequest request,
CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var emoji = request.Emoji?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(emoji))
{
return BadRequest("Reaction emoji is required.");
}
if (emoji.Length > 24)
{
return BadRequest("Reaction emoji is too long.");
}
var message = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken);
if (message is null)
{
return NotFound();
}
if (message.DeletedAt is not null)
{
return BadRequest("Deleted messages cannot be reacted to.");
}
var shouldNotifyReaction = false;
var existingReaction = message.Reactions.FirstOrDefault(reaction => reaction.UserId == userId);
if (existingReaction is not null && string.Equals(existingReaction.Emoji, emoji, StringComparison.Ordinal))
{
dbContext.MessageReactions.Remove(existingReaction);
}
else if (existingReaction is not null)
{
existingReaction.Emoji = emoji;
existingReaction.CreatedAt = DateTimeOffset.UtcNow;
shouldNotifyReaction = message.SenderId != userId;
}
else
{
dbContext.MessageReactions.Add(new MessageReaction
{
MessageId = message.Id,
UserId = userId,
Emoji = emoji,
CreatedAt = DateTimeOffset.UtcNow
});
shouldNotifyReaction = message.SenderId != userId;
}
if (shouldNotifyReaction)
{
pushDispatchQueue.StageReaction(dbContext, chatId, messageId, userId, emoji);
}
await dbContext.SaveChangesAsync(cancellationToken);
var updatedMessage = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken)
?? throw new InvalidOperationException("The reacted message could not be reloaded.");
var dto = updatedMessage.ToDto(presenceTracker, userId);
await NotifyMessageUpdatedAsync(updatedMessage.Chat, [updatedMessage], cancellationToken);
if (shouldNotifyReaction)
{
await pushDispatchQueue.EnqueueReactionAsync(
chatId,
messageId,
userId,
emoji,
cancellationToken);
}
return Ok(dto);
}
[HttpPut("{chatId:guid}/messages/{messageId:guid}/pin")]
public Task<ActionResult<MessageDto>> PinMessage(Guid chatId, Guid messageId, CancellationToken cancellationToken) =>
SetMessagePinAsync(chatId, messageId, isPinned: true, cancellationToken);
[HttpDelete("{chatId:guid}/messages/{messageId:guid}/pin")]
public Task<ActionResult<MessageDto>> UnpinMessage(Guid chatId, Guid messageId, CancellationToken cancellationToken) =>
SetMessagePinAsync(chatId, messageId, isPinned: false, cancellationToken);
[HttpPost("{chatId:guid}/attachments")]
[RequestFormLimits(MultipartBodyLengthLimit = MaxAttachmentSizeBytes)]
[RequestSizeLimit(MaxAttachmentSizeBytes)]
public async Task<ActionResult<MessageDto>> UploadAttachment(
Guid chatId,
[FromForm] UploadAttachmentRequest request,
CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
if (request.File is null)
{
return BadRequest("File is required.");
}
if (request.File.Length <= 0)
{
return BadRequest("File is empty.");
}
if (request.File.Length > MaxAttachmentSizeBytes)
{
return BadRequest($"File exceeds {MaxAttachmentSizeBytes} bytes.");
}
var chat = await dbContext.Chats
.Include(x => x.Members)
.FirstOrDefaultAsync(x => x.Id == chatId && x.Members.Any(m => m.UserId == userId), cancellationToken);
if (chat is null)
{
return NotFound();
}
if (!CanUserPublish(chat, userId))
{
return StatusCode(StatusCodes.Status403Forbidden, "Only channel owners can publish.");
}
var replyToMessage = await ResolveReplyTargetAsync(chatId, request.ReplyToMessageId, userId, cancellationToken);
if (request.ReplyToMessageId is not null && replyToMessage is null)
{
return NotFound("The replied message was not found.");
}
if (request.TopicId is not null)
{
var topic = await dbContext.ChatTopics
.SingleOrDefaultAsync(x => x.Id == request.TopicId.Value && x.ChatId == chatId, cancellationToken);
if (topic is null || topic.IsClosed)
{
return NotFound("The topic was not found.");
}
topic.LastActivityAt = DateTimeOffset.UtcNow;
}
var sender = await dbContext.Users.SingleAsync(x => x.Id == userId, cancellationToken);
var text = request.Text?.Trim() ?? string.Empty;
var ttlSeconds = NormalizeTtlSeconds(request.TtlSeconds ?? chat.DefaultMessageTtlSeconds, allowNull: true);
var sentAt = DateTimeOffset.UtcNow;
var commentedChannelPost = replyToMessage is null
? null
: await LoadCommentedChannelPostAsync(replyToMessage, cancellationToken);
if (commentedChannelPost is not null)
{
commentedChannelPost.CommentCount++;
}
var message = new Message
{
ChatId = chatId,
Chat = chat,
SenderId = userId,
Sender = sender,
PostedAsChannel = ShouldPostAsChannel(chat),
AuthorSignature = ResolveChannelAuthorSignature(chat, sender),
Text = text,
ReplyToMessageId = replyToMessage?.Id,
ReplyToMessage = replyToMessage,
TopicId = request.TopicId,
HasProtectedContent = request.HasProtectedContent ?? chat.HasProtectedContent,
TtlSeconds = ttlSeconds,
ExpiresAt = ttlSeconds is null ? null : sentAt.AddSeconds(ttlSeconds.Value),
SentAt = sentAt
};
MessageAttachment? attachment = null;
Message? discussionRootMessage = null;
try
{
attachment = await attachmentStorageService.SaveAsync(message.Id, request.File, cancellationToken);
if (request.IsVoiceNote)
{
attachment.Kind = AttachmentKind.VoiceNote;
}
message.Attachments.Add(attachment);
chat.LastActivityAt = message.SentAt;
dbContext.Messages.Add(message);
discussionRootMessage = await ChannelDiscussionMessageFactory.CreateLinkedDiscussionRootAsync(
dbContext,
attachmentStorageService,
chat,
message,
sender,
sentAt,
cancellationToken);
if (discussionRootMessage is not null)
{
message.DiscussionMessageId = discussionRootMessage.Id;
}
pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage);
await dbContext.SaveChangesAsync(cancellationToken);
}
catch
{
if (attachment is not null)
{
attachmentStorageService.DeleteIfExists(attachment.StoragePath);
}
if (discussionRootMessage is not null)
{
DeleteFiles(discussionRootMessage.Attachments.Select(x => x.StoragePath));
}
throw;
}
var loadedMessage = await LoadMessageForMemberAsync(chatId, message.Id, userId, cancellationToken)
?? throw new InvalidOperationException("The uploaded message could not be reloaded.");
var loadedDiscussionRootMessage = discussionRootMessage is null
? null
: await LoadMessageForNotificationAsync(discussionRootMessage, cancellationToken);
var updatedCommentedChannelPost = commentedChannelPost is null
? null
: await LoadMessageForMemberAsync(
commentedChannelPost.ChatId,
commentedChannelPost.Id,
commentedChannelPost.SenderId,
cancellationToken);
var dto = loadedMessage.ToDto(presenceTracker, userId);
await NotifyMessageCreatedAsync(loadedMessage.Chat, loadedMessage, cancellationToken);
await botUpdateService.EnqueueIncomingMessageAsync(loadedMessage.Id, cancellationToken);
await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id);
if (loadedDiscussionRootMessage is not null)
{
await NotifyMessageCreatedAsync(loadedDiscussionRootMessage.Chat, loadedDiscussionRootMessage, cancellationToken);
await botUpdateService.EnqueueIncomingMessageAsync(loadedDiscussionRootMessage.Id, cancellationToken);
await pushDispatchQueue.EnqueueAsync(loadedDiscussionRootMessage.ChatId, loadedDiscussionRootMessage.Id);
}
if (updatedCommentedChannelPost is not null)
{
await NotifyMessageUpdatedAsync(updatedCommentedChannelPost.Chat, [updatedCommentedChannelPost], cancellationToken);
}
return Ok(dto);
}
[HttpPost("{chatId:guid}/attachments/album")]
[RequestFormLimits(MultipartBodyLengthLimit = MaxAlbumSizeBytes)]
[RequestSizeLimit(MaxAlbumSizeBytes)]
public async Task<ActionResult<MessageDto>> UploadAttachmentAlbum(
Guid chatId,
[FromForm] UploadAttachmentAlbumRequest request,
CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var files = request.Files
.Where(file => file is not null)
.ToList();
if (files.Count < 2)
{
return BadRequest("Albums must contain at least 2 files.");
}
if (files.Count > MaxAlbumAttachments)
{
return BadRequest($"Albums can contain up to {MaxAlbumAttachments} files.");
}
if (files.Any(file => file.Length <= 0))
{
return BadRequest("Album contains an empty file.");
}
if (files.Any(file => file.Length > MaxAttachmentSizeBytes) || files.Sum(file => file.Length) > MaxAlbumSizeBytes)
{
return BadRequest($"Album exceeds {MaxAlbumSizeBytes} bytes or contains a file over {MaxAttachmentSizeBytes} bytes.");
}
if (files.Any(file => !IsMediaAlbumFile(file)))
{
return BadRequest("Albums accept only image and video files.");
}
var chat = await dbContext.Chats
.Include(x => x.Members)
.FirstOrDefaultAsync(x => x.Id == chatId && x.Members.Any(m => m.UserId == userId), cancellationToken);
if (chat is null)
{
return NotFound();
}
if (!CanUserPublish(chat, userId))
{
return StatusCode(StatusCodes.Status403Forbidden, "You cannot publish in this chat.");
}
var replyToMessage = await ResolveReplyTargetAsync(chatId, request.ReplyToMessageId, userId, cancellationToken);
if (request.ReplyToMessageId is not null && replyToMessage is null)
{
return NotFound("The replied message was not found.");
}
if (request.TopicId is not null)
{
var topic = await dbContext.ChatTopics
.SingleOrDefaultAsync(x => x.Id == request.TopicId.Value && x.ChatId == chatId, cancellationToken);
if (topic is null || topic.IsClosed)
{
return NotFound("The topic was not found.");
}
topic.LastActivityAt = DateTimeOffset.UtcNow;
}
var sender = await dbContext.Users.SingleAsync(x => x.Id == userId, cancellationToken);
var ttlSeconds = NormalizeTtlSeconds(request.TtlSeconds ?? chat.DefaultMessageTtlSeconds, allowNull: true);
var sentAt = DateTimeOffset.UtcNow;
var commentedChannelPost = replyToMessage is null
? null
: await LoadCommentedChannelPostAsync(replyToMessage, cancellationToken);
if (commentedChannelPost is not null)
{
commentedChannelPost.CommentCount++;
}
var message = new Message
{
ChatId = chatId,
Chat = chat,
SenderId = userId,
Sender = sender,
PostedAsChannel = ShouldPostAsChannel(chat),
AuthorSignature = ResolveChannelAuthorSignature(chat, sender),
Text = request.Text?.Trim() ?? string.Empty,
ReplyToMessageId = replyToMessage?.Id,
ReplyToMessage = replyToMessage,
TopicId = request.TopicId,
MediaAlbumId = Guid.NewGuid(),
HasProtectedContent = request.HasProtectedContent ?? chat.HasProtectedContent,
TtlSeconds = ttlSeconds,
ExpiresAt = ttlSeconds is null ? null : sentAt.AddSeconds(ttlSeconds.Value),
SentAt = sentAt
};
var savedAttachments = new List<MessageAttachment>();
Message? discussionRootMessage = null;
try
{
for (var index = 0; index < files.Count; index++)
{
var attachment = await attachmentStorageService.SaveAsync(message.Id, files[index], cancellationToken, sortOrder: index);
savedAttachments.Add(attachment);
message.Attachments.Add(attachment);
}
chat.LastActivityAt = message.SentAt;
dbContext.Messages.Add(message);
discussionRootMessage = await ChannelDiscussionMessageFactory.CreateLinkedDiscussionRootAsync(
dbContext,
attachmentStorageService,
chat,
message,
sender,
sentAt,
cancellationToken);
if (discussionRootMessage is not null)
{
message.DiscussionMessageId = discussionRootMessage.Id;
}
pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage);
await dbContext.SaveChangesAsync(cancellationToken);
}
catch
{
foreach (var attachment in savedAttachments)
{
attachmentStorageService.DeleteIfExists(attachment.StoragePath);
}
if (discussionRootMessage is not null)
{
DeleteFiles(discussionRootMessage.Attachments.Select(x => x.StoragePath));
}
throw;
}
var loadedMessage = await LoadMessageForMemberAsync(chatId, message.Id, userId, cancellationToken)
?? throw new InvalidOperationException("The uploaded album could not be reloaded.");
var loadedDiscussionRootMessage = discussionRootMessage is null
? null
: await LoadMessageForNotificationAsync(discussionRootMessage, cancellationToken);
var updatedCommentedChannelPost = commentedChannelPost is null
? null
: await LoadMessageForMemberAsync(
commentedChannelPost.ChatId,
commentedChannelPost.Id,
commentedChannelPost.SenderId,
cancellationToken);
var dto = loadedMessage.ToDto(presenceTracker, userId);
await NotifyMessageCreatedAsync(loadedMessage.Chat, loadedMessage, cancellationToken);
await botUpdateService.EnqueueIncomingMessageAsync(loadedMessage.Id, cancellationToken);
await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id);
if (loadedDiscussionRootMessage is not null)
{
await NotifyMessageCreatedAsync(loadedDiscussionRootMessage.Chat, loadedDiscussionRootMessage, cancellationToken);
await botUpdateService.EnqueueIncomingMessageAsync(loadedDiscussionRootMessage.Id, cancellationToken);
await pushDispatchQueue.EnqueueAsync(loadedDiscussionRootMessage.ChatId, loadedDiscussionRootMessage.Id);
}
if (updatedCommentedChannelPost is not null)
{
await NotifyMessageUpdatedAsync(updatedCommentedChannelPost.Chat, [updatedCommentedChannelPost], cancellationToken);
}
return Ok(dto);
}
[HttpPut("{chatId:guid}/messages/{messageId:guid}")]
public async Task<ActionResult<MessageDto>> UpdateMessage(
Guid chatId,
Guid messageId,
UpdateMessageRequest request,
CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var message = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken);
if (message is null)
{
return NotFound();
}
if (!CanEditMessage(message, userId))
{
return StatusCode(StatusCodes.Status403Forbidden, "You cannot edit this message.");
}
if (message.DeletedAt is not null)
{
return BadRequest("Deleted messages cannot be edited.");
}
var text = request.Text?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(text) && message.Attachments.Count == 0)
{
return BadRequest("Message text is required.");
}
message.Text = text;
message.EditedAt = DateTimeOffset.UtcNow;
var discussionRootMessage = await LoadLinkedDiscussionRootForMutationAsync(message, cancellationToken);
if (discussionRootMessage is not null)
{
discussionRootMessage.Text = ChannelDiscussionMessageFactory.BuildDiscussionRootText(message);
discussionRootMessage.EditedAt = message.EditedAt;
}
await dbContext.SaveChangesAsync(cancellationToken);
var updatedMessage = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken)
?? throw new InvalidOperationException("The updated message could not be reloaded.");
var updatedDiscussionRootMessage = discussionRootMessage is null
? null
: await LoadMessageForNotificationAsync(discussionRootMessage, cancellationToken);
var dto = updatedMessage.ToDto(presenceTracker, userId);
await NotifyMessageUpdatedAsync(updatedMessage.Chat, [updatedMessage], cancellationToken);
if (updatedDiscussionRootMessage is not null)
{
await NotifyMessageUpdatedAsync(updatedDiscussionRootMessage.Chat, [updatedDiscussionRootMessage], cancellationToken);
}
return Ok(dto);
}
[HttpDelete("{chatId:guid}/messages/{messageId:guid}")]
public async Task<ActionResult<MessageDto>> DeleteMessage(Guid chatId, Guid messageId, CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var message = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken);
if (message is null)
{
return NotFound();
}
if (message.DeletedAt is not null)
{
return Ok(message.ToDto(presenceTracker, userId));
}
if (message.SenderId != userId && !ResolvePermissions(message.Chat, userId).CanDeleteMessages)
{
return StatusCode(StatusCodes.Status403Forbidden, "You cannot delete this message.");
}
var discussionRootMessage = await LoadLinkedDiscussionRootForMutationAsync(message, cancellationToken);
var attachments = message.Attachments.ToList();
foreach (var attachment in attachments)
{
attachmentStorageService.DeleteIfExists(attachment.StoragePath);
}
if (message.Reactions.Count > 0)
{
dbContext.MessageReactions.RemoveRange(message.Reactions);
message.Reactions.Clear();
}
dbContext.MessageAttachments.RemoveRange(attachments);
message.Attachments.Clear();
message.Text = string.Empty;
message.EditedAt = null;
message.DeletedAt = DateTimeOffset.UtcNow;
message.PinnedAt = null;
message.PinnedByUserId = null;
message.PinnedByUser = null;
if (discussionRootMessage is not null && discussionRootMessage.DeletedAt is null)
{
var discussionAttachments = discussionRootMessage.Attachments.ToList();
foreach (var attachment in discussionAttachments)
{
attachmentStorageService.DeleteIfExists(attachment.StoragePath);
}
if (discussionRootMessage.Reactions.Count > 0)
{
dbContext.MessageReactions.RemoveRange(discussionRootMessage.Reactions);
discussionRootMessage.Reactions.Clear();
}
if (discussionRootMessage.Views.Count > 0)
{
dbContext.MessageViews.RemoveRange(discussionRootMessage.Views);
discussionRootMessage.Views.Clear();
}
dbContext.MessageAttachments.RemoveRange(discussionAttachments);
discussionRootMessage.Attachments.Clear();
discussionRootMessage.Text = string.Empty;
discussionRootMessage.EditedAt = null;
discussionRootMessage.DeletedAt = message.DeletedAt;
discussionRootMessage.PinnedAt = null;
discussionRootMessage.PinnedByUserId = null;
discussionRootMessage.PinnedByUser = null;
}
await AddModerationLogAsync(userId, chatId, ModerationLogTargetType.Message, messageId, "message.deleted", null, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
var dto = message.ToDto(presenceTracker, userId);
await NotifyMessageUpdatedAsync(message.Chat, [message], cancellationToken);
if (discussionRootMessage is not null)
{
await NotifyMessageUpdatedAsync(discussionRootMessage.Chat, [discussionRootMessage], cancellationToken);
}
return Ok(dto);
}
[HttpGet("{chatId:guid}/attachments/{attachmentId:guid}/content")]
public async Task<IActionResult> DownloadAttachment(Guid chatId, Guid attachmentId, CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var now = DateTimeOffset.UtcNow;
var attachment = await dbContext.MessageAttachments
.Include(x => x.Message)
.ThenInclude(x => x.Chat)
.ThenInclude(x => x.Members)
.SingleOrDefaultAsync(
x => x.Id == attachmentId &&
x.Message.ChatId == chatId &&
x.Message.DeletedAt == null &&
(x.Message.ExpiresAt == null || x.Message.ExpiresAt > now) &&
x.Message.Chat.Members.Any(m => m.UserId == userId),
cancellationToken);
if (attachment is null || !System.IO.File.Exists(attachment.StoragePath))
{
return NotFound();
}
var stream = System.IO.File.OpenRead(attachment.StoragePath);
return File(stream, string.IsNullOrWhiteSpace(attachment.ContentType) ? MediaTypeNames.Application.Octet : attachment.ContentType, attachment.OriginalFileName, enableRangeProcessing: true);
}
[HttpPost("messages/cleanup-expired")]
public async Task<ActionResult<ExpiredMessagesCleanupDto>> CleanupExpiredMessages(CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var now = DateTimeOffset.UtcNow;
var messages = await dbContext.Messages
.Include(x => x.Chat)
.ThenInclude(x => x.Members)
.Include(x => x.Attachments)
.Include(x => x.Reactions)
.Include(x => x.Views)
.Where(x =>
x.ExpiresAt != null &&
x.ExpiresAt <= now &&
x.DeletedAt == null &&
x.Chat.Members.Any(member => member.UserId == userId))
.Take(250)
.ToListAsync(cancellationToken);
var affectedChats = messages
.Select(x => x.Chat)
.DistinctBy(x => x.Id)
.ToList();
foreach (var message in messages)
{
DeleteFiles(message.Attachments.Select(x => x.StoragePath));
dbContext.MessageAttachments.RemoveRange(message.Attachments);
dbContext.MessageReactions.RemoveRange(message.Reactions);
message.Attachments.Clear();
message.Reactions.Clear();
message.Text = string.Empty;
message.EditedAt = null;
message.DeletedAt = now;
message.PinnedAt = null;
message.PinnedByUserId = null;
}
await dbContext.SaveChangesAsync(cancellationToken);
foreach (var chat in affectedChats)
{
await NotifyMessageUpdatedAsync(chat, messages.Where(x => x.ChatId == chat.Id).ToList(), cancellationToken);
}
return Ok(new ExpiredMessagesCleanupDto(messages.Count, now));
}
[HttpGet("{chatId:guid}/moderation-log")]
public async Task<ActionResult<IReadOnlyList<ModerationLogDto>>> GetModerationLog(
Guid chatId,
[FromQuery] int limit = 100,
CancellationToken cancellationToken = default)
{
var userId = User.GetRequiredUserId();
var chat = await LoadChatMetadata(chatId, userId, cancellationToken);
if (chat is null)
{
return NotFound();
}
if (!CanManageMembers(chat, userId))
{
return StatusCode(StatusCodes.Status403Forbidden, "Only admins can view moderation logs.");
}
var take = Math.Clamp(limit, 1, 250);
var entries = await dbContext.ModerationLogEntries
.AsNoTracking()
.Include(x => x.ActorUser)
.ThenInclude(x => x.Sessions)
.Where(x => x.ChatId == chatId)
.OrderByDescending(x => x.CreatedAt)
.Take(take)
.ToListAsync(cancellationToken);
return Ok(entries.Select(x => x.ToDto(presenceTracker)).ToList());
}
private async Task<ActionResult<MessageDto>> SetMessagePinAsync(
Guid chatId,
Guid messageId,
bool isPinned,
CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var message = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken);
if (message is null)
{
return NotFound();
}
if (message.DeletedAt is not null)
{
return BadRequest("Deleted messages cannot be pinned.");
}
if (!ResolvePermissions(message.Chat, userId).CanPinMessages)
{
return StatusCode(StatusCodes.Status403Forbidden, "You cannot pin messages in this chat.");
}
if (isPinned)
{
message.PinnedAt = DateTimeOffset.UtcNow;
message.PinnedByUserId = userId;
}
else
{
message.PinnedAt = null;
message.PinnedByUserId = null;
message.PinnedByUser = null;
}
await AddModerationLogAsync(
userId,
chatId,
ModerationLogTargetType.Message,
messageId,
isPinned ? "message.pinned" : "message.unpinned",
null,
cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
var updatedMessage = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken)
?? throw new InvalidOperationException("The pinned message could not be reloaded.");
var dto = updatedMessage.ToDto(presenceTracker, userId);
await NotifyMessageUpdatedAsync(updatedMessage.Chat, [updatedMessage], cancellationToken);
return Ok(dto);
}
private async Task<IActionResult> AcknowledgeMessageReceiptAsync(
Guid chatId,
Guid messageId,
Guid userId,
bool markRead,
CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
var targetMessage = await dbContext.Messages
.Include(x => x.Chat)
.ThenInclude(x => x.Members)
.ThenInclude(x => x.User)
.ThenInclude(x => x.Sessions)
.Include(x => x.Chat)
.ThenInclude(x => x.Members)
.ThenInclude(x => x.User)
.ThenInclude(x => x.PrivacySettings)
.Include(x => x.Sender)
.ThenInclude(x => x.Sessions)
.Include(x => x.Sender)
.ThenInclude(x => x.PrivacySettings)
.Include(x => x.PinnedByUser)
.ThenInclude(x => x!.Sessions)
.Include(x => x.PinnedByUser)
.ThenInclude(x => x!.PrivacySettings)
.Include(x => x.Attachments)
.Include(x => x.Reactions)
.Include(x => x.Views)
.Include(x => x.ReplyToMessage)
.ThenInclude(x => x!.Sender)
.ThenInclude(x => x.Sessions)
.Include(x => x.ReplyToMessage)
.ThenInclude(x => x!.Sender)
.ThenInclude(x => x.PrivacySettings)
.Include(x => x.ReplyToMessage)
.ThenInclude(x => x!.Attachments)
.SingleOrDefaultAsync(
x => x.Id == messageId &&
x.ChatId == chatId &&
x.DeletedAt == null &&
(x.ExpiresAt == null || x.ExpiresAt > now) &&
x.Chat.Members.Any(member => member.UserId == userId),
cancellationToken);
if (targetMessage is null)
{
return NotFound();
}
if (targetMessage.SenderId == userId)
{
return Ok();
}
var membership = targetMessage.Chat.Members.First(member => member.UserId == userId);
var previousDeliveredAt = membership.LastDeliveredAt;
var previousReadAt = membership.LastReadAt;
var changed = false;
if (membership.LastDeliveredAt is null || membership.LastDeliveredAt.Value < targetMessage.SentAt)
{
membership.LastDeliveredAt = targetMessage.SentAt;
changed = true;
}
if (markRead && (membership.LastReadAt is null || membership.LastReadAt.Value < targetMessage.SentAt))
{
membership.LastReadAt = targetMessage.SentAt;
changed = true;
}
if (!changed)
{
return Ok();
}
await dbContext.SaveChangesAsync(cancellationToken);
var changedAfter = markRead ? previousReadAt : previousDeliveredAt;
var changedMessages = await LoadReceiptAffectedMessagesAsync(
chatId,
userId,
changedAfter,
targetMessage.SentAt,
cancellationToken);
if (changedMessages.Count > 0)
{
await NotifyMessageUpdatedAsync(targetMessage.Chat, changedMessages, cancellationToken);
}
await NotifyChatsChangedAsync([userId], cancellationToken);
return Ok();
}
private async Task UpdateReadReceiptForOpenedChatAsync(Chat chat, Guid userId, CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
var latestIncomingAt = await dbContext.Messages
.Where(message =>
message.ChatId == chat.Id &&
message.SenderId != userId &&
message.DeletedAt == null &&
(message.ExpiresAt == null || message.ExpiresAt > now))
.Select(message => (DateTimeOffset?)message.SentAt)
.MaxAsync(cancellationToken);
if (latestIncomingAt is null)
{
return;
}
var membership = chat.Members.First(member => member.UserId == userId);
var previousReadAt = membership.LastReadAt;
var previousDeliveredAt = membership.LastDeliveredAt;
var changed = false;
if (membership.LastDeliveredAt is null || membership.LastDeliveredAt.Value < latestIncomingAt.Value)
{
membership.LastDeliveredAt = latestIncomingAt.Value;
changed = true;
}
if (membership.LastReadAt is null || membership.LastReadAt.Value < latestIncomingAt.Value)
{
membership.LastReadAt = latestIncomingAt.Value;
changed = true;
}
if (!changed)
{
return;
}
await dbContext.SaveChangesAsync(cancellationToken);
var changedAfter = previousReadAt ?? previousDeliveredAt;
var changedMessages = await LoadReceiptAffectedMessagesAsync(
chat.Id,
userId,
changedAfter,
latestIncomingAt.Value,
cancellationToken);
if (changedMessages.Count > 0)
{
await NotifyMessageUpdatedAsync(chat, changedMessages, cancellationToken);
}
await NotifyChatsChangedAsync([userId], cancellationToken);
}
private async Task MarkChannelMessagesViewedAsync(
Chat chat,
Guid userId,
IReadOnlyCollection<Message> messages,
CancellationToken cancellationToken)
{
if (chat.Type != ChatType.Channel || messages.Count == 0)
{
return;
}
var messageIds = messages
.Where(message => message.PostedAsChannel && message.DeletedAt is null)
.Select(message => message.Id)
.Distinct()
.ToList();
if (messageIds.Count == 0)
{
return;
}
var existingViewedMessageIds = await dbContext.MessageViews
.AsNoTracking()
.Where(view => view.ViewerUserId == userId && messageIds.Contains(view.MessageId))
.Select(view => view.MessageId)
.ToListAsync(cancellationToken);
var existingSet = existingViewedMessageIds.ToHashSet();
var viewedAt = DateTimeOffset.UtcNow;
var changed = false;
foreach (var message in messages.Where(message => messageIds.Contains(message.Id)))
{
if (!existingSet.Add(message.Id))
{
continue;
}
var view = new MessageView
{
MessageId = message.Id,
ViewerUserId = userId,
ViewedAt = viewedAt
};
dbContext.MessageViews.Add(view);
message.Views.Add(view);
changed = true;
}
if (changed)
{
await dbContext.SaveChangesAsync(cancellationToken);
}
}
private async Task<List<Message>> LoadReceiptAffectedMessagesAsync(
Guid chatId,
Guid recipientUserId,
DateTimeOffset? changedAfter,
DateTimeOffset changedBeforeOrAt,
CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
var messages = await dbContext.Messages
.Include(x => x.Chat)
.ThenInclude(x => x.Members)
.ThenInclude(x => x.User)
.ThenInclude(x => x.Sessions)
.Include(x => x.Chat)
.ThenInclude(x => x.Members)
.ThenInclude(x => x.User)
.ThenInclude(x => x.PrivacySettings)
.Include(x => x.Sender)
.ThenInclude(x => x.Sessions)
.Include(x => x.Sender)
.ThenInclude(x => x.PrivacySettings)
.Include(x => x.PinnedByUser)
.ThenInclude(x => x!.Sessions)
.Include(x => x.PinnedByUser)
.ThenInclude(x => x!.PrivacySettings)
.Include(x => x.Attachments)
.Include(x => x.Reactions)
.Include(x => x.Views)
.Include(x => x.ReplyToMessage)
.ThenInclude(x => x!.Sender)
.ThenInclude(x => x.Sessions)
.Include(x => x.ReplyToMessage)
.ThenInclude(x => x!.Sender)
.ThenInclude(x => x.PrivacySettings)
.Include(x => x.ReplyToMessage)
.ThenInclude(x => x!.Attachments)
.Where(x =>
x.ChatId == chatId &&
x.SenderId != recipientUserId &&
x.DeletedAt == null &&
(x.ExpiresAt == null || x.ExpiresAt > now))
.ToListAsync(cancellationToken);
return messages
.Where(x =>
x.SentAt <= changedBeforeOrAt &&
(changedAfter is null || x.SentAt > changedAfter.Value))
.OrderBy(x => x.SentAt)
.ToList();
}
private async Task<IReadOnlyDictionary<Guid, Message>> LoadLatestMessagesByChatIdAsync(
IReadOnlyCollection<Guid> chatIds,
CancellationToken cancellationToken)
{
if (chatIds.Count == 0)
{
return new Dictionary<Guid, Message>();
}
var now = DateTimeOffset.UtcNow;
var latestSentAtByChatQuery = dbContext.Messages
.Where(message =>
chatIds.Contains(message.ChatId) &&
message.DeletedAt == null &&
(message.ExpiresAt == null || message.ExpiresAt > now))
.GroupBy(message => message.ChatId)
.Select(group => new
{
ChatId = group.Key,
SentAt = group.Max(message => message.SentAt)
});
var latestMessages = await dbContext.Messages
.Where(message =>
message.DeletedAt == null &&
(message.ExpiresAt == null || message.ExpiresAt > now))
.Join(
latestSentAtByChatQuery,
message => new { message.ChatId, message.SentAt },
latest => new { latest.ChatId, latest.SentAt },
(message, _) => message)
.Include(message => message.Sender)
.Include(message => message.PinnedByUser)
.Include(message => message.Attachments)
.AsNoTracking()
.ToListAsync(cancellationToken);
return latestMessages
.GroupBy(message => message.ChatId)
.ToDictionary(
group => group.Key,
group => group.OrderByDescending(message => message.SentAt).First());
}
private async Task<IReadOnlyDictionary<Guid, int>> LoadUnreadCountsByChatIdAsync(
Guid userId,
IReadOnlyCollection<Guid> chatIds,
CancellationToken cancellationToken)
{
if (chatIds.Count == 0)
{
return new Dictionary<Guid, int>();
}
var now = DateTimeOffset.UtcNow;
var unreadCounts = await dbContext.Messages
.Where(message =>
chatIds.Contains(message.ChatId) &&
message.SenderId != userId &&
message.DeletedAt == null &&
(message.ExpiresAt == null || message.ExpiresAt > now))
.Join(
dbContext.ChatMembers.Where(member => member.UserId == userId),
message => message.ChatId,
member => member.ChatId,
(message, member) => new
{
message.ChatId,
message.SentAt,
member.LastReadAt
})
.Where(x => x.LastReadAt == null || x.SentAt > x.LastReadAt.Value)
.GroupBy(x => x.ChatId)
.Select(group => new
{
ChatId = group.Key,
Count = group.Count()
})
.ToListAsync(cancellationToken);
return unreadCounts.ToDictionary(x => x.ChatId, x => x.Count);
}
private async Task<IReadOnlyList<Message>> LoadPinnedMessagesAsync(Chat chat, CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
var messages = await dbContext.Messages
.Where(message =>
message.ChatId == chat.Id &&
message.PinnedAt != null &&
message.DeletedAt == null &&
(message.ExpiresAt == null || message.ExpiresAt > now))
.OrderByDescending(message => message.PinnedAt)
.Take(5)
.Include(message => message.Sender)
.ThenInclude(user => user.Sessions)
.Include(message => message.Sender)
.ThenInclude(user => user.PrivacySettings)
.Include(message => message.PinnedByUser)
.ThenInclude(user => user!.Sessions)
.Include(message => message.PinnedByUser)
.ThenInclude(user => user!.PrivacySettings)
.Include(message => message.Attachments)
.Include(message => message.Reactions)
.Include(message => message.Views)
.Include(message => message.ReplyToMessage)
.ThenInclude(message => message!.Sender)
.ThenInclude(user => user.Sessions)
.Include(message => message.ReplyToMessage)
.ThenInclude(message => message!.Sender)
.ThenInclude(user => user.PrivacySettings)
.Include(message => message.ReplyToMessage)
.ThenInclude(message => message!.Attachments)
.AsNoTracking()
.ToListAsync(cancellationToken);
foreach (var message in messages)
{
message.Chat = chat;
}
return messages;
}
private sealed record MessagesPage(IReadOnlyList<Message> Messages, bool HasMoreMessages);
private async Task<MessagesPage> LoadMessagesPageAsync(
Chat chat,
Guid? beforeMessageId,
int requestedLimit,
CancellationToken cancellationToken)
{
var limit = Math.Clamp(requestedLimit, 1, MaxMessagePageSize);
var now = DateTimeOffset.UtcNow;
var query = dbContext.Messages.Where(message =>
message.ChatId == chat.Id &&
message.DeletedAt == null &&
(message.ExpiresAt == null || message.ExpiresAt > now));
if (beforeMessageId is not null)
{
var boundary = await dbContext.Messages
.AsNoTracking()
.Where(message =>
message.ChatId == chat.Id &&
message.Id == beforeMessageId.Value &&
message.DeletedAt == null &&
(message.ExpiresAt == null || message.ExpiresAt > now))
.Select(message => new { message.SentAt })
.SingleOrDefaultAsync(cancellationToken);
if (boundary is null)
{
return new MessagesPage([], HasMoreMessages: false);
}
query = query.Where(message => message.SentAt < boundary.SentAt);
}
var messages = await query
.OrderByDescending(message => message.SentAt)
.Take(limit + 1)
.Include(message => message.Sender)
.ThenInclude(user => user.Sessions)
.Include(message => message.Sender)
.ThenInclude(user => user.PrivacySettings)
.Include(message => message.PinnedByUser)
.ThenInclude(user => user!.Sessions)
.Include(message => message.PinnedByUser)
.ThenInclude(user => user!.PrivacySettings)
.Include(message => message.Attachments)
.Include(message => message.Reactions)
.Include(message => message.Views)
.Include(message => message.ReplyToMessage)
.ThenInclude(message => message!.Sender)
.ThenInclude(user => user.Sessions)
.Include(message => message.ReplyToMessage)
.ThenInclude(message => message!.Sender)
.ThenInclude(user => user.PrivacySettings)
.Include(message => message.ReplyToMessage)
.ThenInclude(message => message!.Attachments)
.AsNoTracking()
.ToListAsync(cancellationToken);
var hasMoreMessages = messages.Count > limit;
var pageMessages = messages
.Take(limit)
.OrderBy(message => message.SentAt)
.ToList();
foreach (var message in pageMessages)
{
message.Chat = chat;
}
return new MessagesPage(pageMessages, hasMoreMessages);
}
private async Task<MessagesPage> LoadMessagesAroundAsync(
Chat chat,
Guid aroundMessageId,
int requestedLimit,
CancellationToken cancellationToken)
{
var limit = Math.Clamp(requestedLimit, 1, MaxMessagePageSize);
var now = DateTimeOffset.UtcNow;
var boundary = await dbContext.Messages
.AsNoTracking()
.Where(message =>
message.ChatId == chat.Id &&
message.Id == aroundMessageId &&
message.DeletedAt == null &&
(message.ExpiresAt == null || message.ExpiresAt > now))
.Select(message => new { message.SentAt })
.SingleOrDefaultAsync(cancellationToken);
if (boundary is null)
{
return new MessagesPage([], HasMoreMessages: false);
}
var messages = await dbContext.Messages
.Where(message =>
message.ChatId == chat.Id &&
message.DeletedAt == null &&
(message.ExpiresAt == null || message.ExpiresAt > now) &&
message.SentAt >= boundary.SentAt)
.OrderBy(message => message.SentAt)
.Take(limit)
.Include(message => message.Sender)
.ThenInclude(user => user.Sessions)
.Include(message => message.Sender)
.ThenInclude(user => user.PrivacySettings)
.Include(message => message.PinnedByUser)
.ThenInclude(user => user!.Sessions)
.Include(message => message.PinnedByUser)
.ThenInclude(user => user!.PrivacySettings)
.Include(message => message.Attachments)
.Include(message => message.Reactions)
.Include(message => message.Views)
.Include(message => message.ReplyToMessage)
.ThenInclude(message => message!.Sender)
.ThenInclude(user => user.Sessions)
.Include(message => message.ReplyToMessage)
.ThenInclude(message => message!.Sender)
.ThenInclude(user => user.PrivacySettings)
.Include(message => message.ReplyToMessage)
.ThenInclude(message => message!.Attachments)
.AsNoTracking()
.ToListAsync(cancellationToken);
foreach (var message in messages)
{
message.Chat = chat;
}
var hasMoreMessages = await dbContext.Messages
.AnyAsync(
message =>
message.ChatId == chat.Id &&
message.DeletedAt == null &&
(message.ExpiresAt == null || message.ExpiresAt > now) &&
message.SentAt < boundary.SentAt,
cancellationToken);
return new MessagesPage(messages, hasMoreMessages);
}
private async Task<Chat?> LoadChatMetadata(
Guid chatId,
Guid userId,
CancellationToken cancellationToken,
bool asNoTracking = true,
bool allowPublicChannelPreview = false)
{
var query = dbContext.Chats
.Include(x => x.Members)
.ThenInclude(x => x.User)
.ThenInclude(x => x.Sessions)
.Include(x => x.Members)
.ThenInclude(x => x.User)
.ThenInclude(x => x.PrivacySettings)
.AsSplitQuery();
if (asNoTracking)
{
query = query.AsNoTracking();
}
return await query.SingleOrDefaultAsync(
x => x.Id == chatId &&
(x.Members.Any(m => m.UserId == userId) ||
(allowPublicChannelPreview &&
x.Type == ChatType.Channel &&
x.PublicUsername != null &&
x.PublicUsername != string.Empty)),
cancellationToken);
}
private async Task<Chat?> LoadChatWithMessages(Guid chatId, Guid userId, CancellationToken cancellationToken, bool asNoTracking = true)
{
var query = dbContext.Chats
.Include(x => x.Members)
.ThenInclude(x => x.User)
.ThenInclude(x => x.Sessions)
.Include(x => x.Members)
.ThenInclude(x => x.User)
.ThenInclude(x => x.PrivacySettings)
.Include(x => x.Messages)
.ThenInclude(x => x.Sender)
.ThenInclude(x => x.Sessions)
.Include(x => x.Messages)
.ThenInclude(x => x.Sender)
.ThenInclude(x => x.PrivacySettings)
.Include(x => x.Messages)
.ThenInclude(x => x.PinnedByUser)
.ThenInclude(x => x!.Sessions)
.Include(x => x.Messages)
.ThenInclude(x => x.PinnedByUser)
.ThenInclude(x => x!.PrivacySettings)
.Include(x => x.Messages)
.ThenInclude(x => x.Attachments)
.Include(x => x.Messages)
.ThenInclude(x => x.Reactions)
.Include(x => x.Messages)
.ThenInclude(x => x.Views)
.Include(x => x.Messages)
.ThenInclude(x => x.ReplyToMessage)
.ThenInclude(x => x!.Sender)
.ThenInclude(x => x.Sessions)
.Include(x => x.Messages)
.ThenInclude(x => x.ReplyToMessage)
.ThenInclude(x => x!.Sender)
.ThenInclude(x => x.PrivacySettings)
.Include(x => x.Messages)
.ThenInclude(x => x.ReplyToMessage)
.ThenInclude(x => x!.Attachments)
.AsSplitQuery();
if (asNoTracking)
{
query = query.AsNoTracking();
}
return await query.SingleOrDefaultAsync(x => x.Id == chatId && x.Members.Any(m => m.UserId == userId), cancellationToken);
}
private async Task<Story?> LoadVisibleStoryForReplyAsync(
Guid storyId,
Guid userId,
CancellationToken cancellationToken)
{
var peerAuthorIds = await LoadStoryPeerAuthorIdsAsync(userId, cancellationToken);
var now = DateTimeOffset.UtcNow;
return await dbContext.Stories
.AsNoTracking()
.Include(x => x.Attachments)
.Where(x =>
x.Id == storyId &&
x.DeletedAt == null &&
x.ExpiresAt > now)
.WhereVisibleTo(userId, peerAuthorIds)
.SingleOrDefaultAsync(cancellationToken);
}
private async Task<List<Guid>> LoadStoryPeerAuthorIdsAsync(Guid userId, CancellationToken cancellationToken) =>
await dbContext.ChatMembers
.AsNoTracking()
.Where(member => member.UserId == userId)
.Join(
dbContext.ChatMembers.AsNoTracking().Where(member => member.UserId != userId),
mine => mine.ChatId,
peer => peer.ChatId,
(_, peer) => peer.UserId)
.Distinct()
.ToListAsync(cancellationToken);
private static string BuildStoryReplyPreview(Story story)
{
var textPreview = story.Text
.Trim()
.ReplaceLineEndings(" ")
.Trim()
.Take(160)
.ToArray();
var normalizedTextPreview = new string(textPreview);
if (!string.IsNullOrWhiteSpace(normalizedTextPreview))
{
return normalizedTextPreview;
}
if (story.Attachments.Count > 1)
{
return $"{story.Attachments.Count} медиа";
}
var contentType = story.Attachments.FirstOrDefault()?.ContentType?.Trim().ToLowerInvariant();
return contentType switch
{
not null when contentType.StartsWith("image/", StringComparison.Ordinal) => "Фото сторис",
not null when contentType.StartsWith("video/", StringComparison.Ordinal) => "Видео сторис",
not null when contentType.StartsWith("audio/", StringComparison.Ordinal) => "Аудио сторис",
not null => "Медиа сторис",
_ => "Сторис"
};
}
private static string ResolveStoryReplyAttachmentKind(Story story)
{
if (story.Attachments.Count == 0)
{
return "text";
}
if (story.Attachments.Count > 1)
{
return "media";
}
var contentType = story.Attachments.First().ContentType.Trim().ToLowerInvariant();
return contentType switch
{
_ when contentType.StartsWith("image/", StringComparison.Ordinal) => "image",
_ when contentType.StartsWith("video/", StringComparison.Ordinal) => "video",
_ when contentType.StartsWith("audio/", StringComparison.Ordinal) => "audio",
_ => "file"
};
}
private async Task<Message?> LoadMessageForMemberAsync(Guid chatId, Guid messageId, Guid userId, CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
return await dbContext.Messages
.Include(x => x.Chat)
.ThenInclude(x => x.Members)
.ThenInclude(x => x.User)
.ThenInclude(x => x.Sessions)
.Include(x => x.Chat)
.ThenInclude(x => x.Members)
.ThenInclude(x => x.User)
.ThenInclude(x => x.PrivacySettings)
.Include(x => x.Sender)
.ThenInclude(x => x.Sessions)
.Include(x => x.Sender)
.ThenInclude(x => x.PrivacySettings)
.Include(x => x.PinnedByUser)
.ThenInclude(x => x!.Sessions)
.Include(x => x.PinnedByUser)
.ThenInclude(x => x!.PrivacySettings)
.Include(x => x.Attachments)
.Include(x => x.Reactions)
.Include(x => x.Views)
.Include(x => x.ReplyToMessage)
.ThenInclude(x => x!.Sender)
.ThenInclude(x => x.Sessions)
.Include(x => x.ReplyToMessage)
.ThenInclude(x => x!.Sender)
.ThenInclude(x => x.PrivacySettings)
.Include(x => x.ReplyToMessage)
.ThenInclude(x => x!.Attachments)
.SingleOrDefaultAsync(
x => x.Id == messageId &&
x.ChatId == chatId &&
x.DeletedAt == null &&
(x.ExpiresAt == null || x.ExpiresAt > now) &&
x.Chat.Members.Any(m => m.UserId == userId),
cancellationToken);
}
private Task<Message?> LoadMessageForNotificationAsync(Message message, CancellationToken cancellationToken)
{
var viewerUserId = message.Chat.Members
.OrderByDescending(x => x.IsOwner)
.ThenBy(x => x.JoinedAt)
.Select(x => x.UserId)
.FirstOrDefault();
if (viewerUserId == Guid.Empty)
{
viewerUserId = message.SenderId;
}
return LoadMessageForMemberAsync(message.ChatId, message.Id, viewerUserId, cancellationToken);
}
private async Task<Message?> LoadCommentedChannelPostAsync(Message replyToMessage, CancellationToken cancellationToken)
{
var currentMessageId = (Guid?)replyToMessage.Id;
var visitedMessageIds = new HashSet<Guid>();
while (currentMessageId is not null && visitedMessageIds.Add(currentMessageId.Value))
{
var channelPost = await LoadChannelPostForDiscussionRootAsync(currentMessageId.Value, cancellationToken);
if (channelPost is not null)
{
return channelPost;
}
currentMessageId = await dbContext.Messages
.Where(x => x.Id == currentMessageId.Value && x.ChatId == replyToMessage.ChatId)
.Select(x => x.ReplyToMessageId)
.SingleOrDefaultAsync(cancellationToken);
}
return null;
}
private async Task<Message?> LoadLinkedDiscussionRootForMutationAsync(Message channelPost, CancellationToken cancellationToken)
{
if (channelPost.Chat.Type != ChatType.Channel ||
!channelPost.PostedAsChannel ||
channelPost.DiscussionMessageId is null)
{
return null;
}
return await dbContext.Messages
.Include(x => x.Chat)
.ThenInclude(x => x.Members)
.ThenInclude(x => x.User)
.ThenInclude(x => x.Sessions)
.Include(x => x.Chat)
.ThenInclude(x => x.Members)
.ThenInclude(x => x.User)
.ThenInclude(x => x.PrivacySettings)
.Include(x => x.Sender)
.ThenInclude(x => x.Sessions)
.Include(x => x.Sender)
.ThenInclude(x => x.PrivacySettings)
.Include(x => x.PinnedByUser)
.ThenInclude(x => x!.Sessions)
.Include(x => x.PinnedByUser)
.ThenInclude(x => x!.PrivacySettings)
.Include(x => x.Attachments)
.Include(x => x.Reactions)
.Include(x => x.Views)
.SingleOrDefaultAsync(
x => x.Id == channelPost.DiscussionMessageId.Value &&
x.ChatId == channelPost.Chat.LinkedDiscussionChatId &&
x.Chat.Type == ChatType.Group,
cancellationToken);
}
private async Task<Message?> CreateLinkedDiscussionMessageAsync(
Chat channel,
Message channelMessage,
User sender,
DateTimeOffset sentAt,
CancellationToken cancellationToken)
{
if (channel.Type != ChatType.Channel ||
!channelMessage.PostedAsChannel ||
channel.LinkedDiscussionChatId is null)
{
return null;
}
var discussionChat = await dbContext.Chats
.Include(x => x.Members)
.FirstOrDefaultAsync(
x => x.Id == channel.LinkedDiscussionChatId.Value &&
x.Type == ChatType.Group &&
x.Members.Any(member => member.UserId == sender.Id),
cancellationToken);
if (discussionChat is null)
{
return null;
}
var rootMessage = new Message
{
ChatId = discussionChat.Id,
Chat = discussionChat,
SenderId = sender.Id,
Sender = sender,
Text = BuildDiscussionRootText(channelMessage),
SentAt = sentAt,
ForwardedFromDisplayName = string.IsNullOrWhiteSpace(channel.Title) ? "Канал" : channel.Title,
HasProtectedContent = channelMessage.HasProtectedContent,
TtlSeconds = channelMessage.TtlSeconds,
ExpiresAt = channelMessage.ExpiresAt
};
discussionChat.LastActivityAt = sentAt;
dbContext.Messages.Add(rootMessage);
return rootMessage;
}
private Task<Message?> LoadChannelPostForDiscussionRootAsync(Guid discussionMessageId, CancellationToken cancellationToken) =>
dbContext.Messages
.Include(x => x.Chat)
.ThenInclude(x => x.Members)
.FirstOrDefaultAsync(
x => x.DiscussionMessageId == discussionMessageId &&
x.Chat.Type == ChatType.Channel &&
x.DeletedAt == null,
cancellationToken);
private static string BuildDiscussionRootText(Message channelMessage)
{
var text = channelMessage.Text.Trim();
return string.IsNullOrWhiteSpace(text) ? "Публикация" : text;
}
private Task<Message?> ResolveReplyTargetAsync(
Guid chatId,
Guid? replyToMessageId,
Guid userId,
CancellationToken cancellationToken)
{
if (replyToMessageId is null)
{
return Task.FromResult<Message?>(null);
}
var now = DateTimeOffset.UtcNow;
return dbContext.Messages
.Include(x => x.Sender)
.Include(x => x.Attachments)
.SingleOrDefaultAsync(
x => x.Id == replyToMessageId.Value &&
x.ChatId == chatId &&
x.DeletedAt == null &&
(x.ExpiresAt == null || x.ExpiresAt > now) &&
x.Chat.Members.Any(member => member.UserId == userId),
cancellationToken);
}
private async Task<List<Chat>> LoadChatSummariesForUserAsync(Guid userId, CancellationToken cancellationToken) =>
await dbContext.Chats
.Where(x => x.Members.Any(member => member.UserId == userId))
.Include(x => x.Members)
.ThenInclude(x => x.User)
.ThenInclude(x => x.Sessions)
.Include(x => x.Members)
.ThenInclude(x => x.User)
.ThenInclude(x => x.PrivacySettings)
.AsNoTracking()
.AsSplitQuery()
.ToListAsync(cancellationToken);
private async Task<ChatSearchPageDto> SearchChatsPageAsync(
Guid userId,
string? rawQuery,
SearchFilters filters,
int requestedPage,
int requestedLimit,
CancellationToken cancellationToken)
{
var query = rawQuery?.Trim() ?? string.Empty;
var page = NormalizeSearchPage(requestedPage);
var take = NormalizeSearchLimit(requestedLimit);
if (query.Length < 2 && !filters.HasMessageFilter)
{
return new ChatSearchPageDto([], page, take, HasMore: false);
}
var skip = (page - 1) * take;
var chats = await LoadChatSummariesForUserAsync(userId, cancellationToken);
var chatIds = chats.Select(x => x.Id).ToList();
var latestMessagesByChatId = await LoadLatestMessagesByChatIdAsync(chatIds, cancellationToken);
var unreadCountsByChatId = await LoadUnreadCountsByChatIdAsync(userId, chatIds, cancellationToken);
var summariesByChatId = chats.ToDictionary(
x => x.Id,
x => x.ToSummaryDto(
userId,
presenceTracker,
latestMessagesByChatId.GetValueOrDefault(x.Id),
unreadCountsByChatId.GetValueOrDefault(x.Id)));
var chatMatches = query.Length >= 2 && !filters.HasMessageFilter
? chats
.Concat(await LoadPublicChannelSearchResultsAsync(query, cancellationToken))
.GroupBy(chat => chat.Id)
.Select(group => group.First())
.Where(chat => MatchesChatSearch(chat, query))
.OrderByDescending(chat => chat.LastActivityAt ?? chat.CreatedAt)
.Select(chat =>
{
var summary = summariesByChatId.TryGetValue(chat.Id, out var memberSummary)
? memberSummary
: chat.ToSummaryDto(userId, presenceTracker);
return new ChatSearchResultDto(
summary,
null,
summary.Title,
summary.LastActivityAt,
summary.Id);
})
.ToList()
: [];
var selectedChatMatches = chatMatches
.Skip(skip)
.Take(take)
.ToList();
var messageSkip = Math.Max(0, skip - chatMatches.Count);
var messageTake = take - selectedChatMatches.Count;
var results = new List<ChatSearchResultDto>(selectedChatMatches);
var messageHasMore = false;
if (messageTake > 0)
{
var messageMatches = await LoadSearchMessagesAsync(
chatIds,
query,
filters,
messageSkip,
messageTake + 1,
cancellationToken);
messageHasMore = messageMatches.Count > messageTake;
foreach (var message in messageMatches.Take(messageTake))
{
if (!summariesByChatId.TryGetValue(message.ChatId, out var summary))
{
continue;
}
results.Add(ToSearchResult(summary, message, userId));
}
}
var hasMore = skip + take < chatMatches.Count || messageHasMore;
return new ChatSearchPageDto(results, page, take, hasMore);
}
private async Task<List<Chat>> LoadPublicChannelSearchResultsAsync(
string? query,
CancellationToken cancellationToken,
int? limit = null)
{
var normalizedQuery = query?.Trim() ?? string.Empty;
var take = limit is null ? (int?)null : Math.Max(limit.Value, 1);
var candidateTake = take is null ? (int?)null : Math.Min(take.Value * 3, 300);
var channelsQuery = dbContext.Chats
.Where(chat =>
chat.Type == ChatType.Channel &&
chat.PublicUsername != null &&
chat.PublicUsername != string.Empty);
if (!string.IsNullOrWhiteSpace(normalizedQuery))
{
var queryPattern = BuildLikePattern(normalizedQuery);
var upperQueryPattern = BuildLikePattern(normalizedQuery.ToUpperInvariant());
var lowerQueryPattern = BuildLikePattern(normalizedQuery.ToLowerInvariant());
var usernameQuery = NormalizePublicSearchUsername(normalizedQuery);
var usernamePattern = string.IsNullOrWhiteSpace(usernameQuery)
? null
: BuildLikePattern(usernameQuery);
channelsQuery = channelsQuery.Where(chat =>
EF.Functions.Like(chat.Title, queryPattern, "\\") ||
EF.Functions.Like(chat.Title, upperQueryPattern, "\\") ||
EF.Functions.Like(chat.Title, lowerQueryPattern, "\\") ||
EF.Functions.Like(chat.PublicUsername!, queryPattern, "\\") ||
EF.Functions.Like(chat.PublicUsername!, upperQueryPattern, "\\") ||
EF.Functions.Like(chat.PublicUsername!, lowerQueryPattern, "\\") ||
(usernamePattern != null && EF.Functions.Like(chat.PublicUsername!, usernamePattern, "\\")) ||
(chat.Description != null && EF.Functions.Like(chat.Description, queryPattern, "\\")) ||
(chat.Description != null && EF.Functions.Like(chat.Description, upperQueryPattern, "\\")) ||
(chat.Description != null && EF.Functions.Like(chat.Description, lowerQueryPattern, "\\")));
}
channelsQuery = channelsQuery
.OrderByDescending(chat => chat.LastActivityAt ?? chat.CreatedAt)
.ThenBy(chat => chat.Title);
if (candidateTake is not null)
{
channelsQuery = channelsQuery.Take(candidateTake.Value);
}
var channels = await channelsQuery
.Include(chat => chat.Members)
.ThenInclude(member => member.User)
.AsNoTracking()
.AsSplitQuery()
.ToListAsync(cancellationToken);
IEnumerable<Chat> matches = channels;
if (!string.IsNullOrWhiteSpace(normalizedQuery))
{
matches = matches.Where(chat => MatchesPublicChannelSearch(chat, normalizedQuery));
}
if (take is not null)
{
matches = matches.Take(take.Value);
}
return matches.ToList();
}
private async Task<MessageSearchPageDto> SearchMessagesPageAsync(
Chat chat,
Guid userId,
string? rawQuery,
SearchFilters filters,
int requestedPage,
int requestedLimit,
CancellationToken cancellationToken)
{
var query = rawQuery?.Trim() ?? string.Empty;
var page = NormalizeSearchPage(requestedPage);
var take = NormalizeSearchLimit(requestedLimit);
if (query.Length < 2 && !filters.HasMessageFilter)
{
return new MessageSearchPageDto([], page, take, HasMore: false);
}
var messages = await LoadSearchMessagesAsync(
[chat.Id],
query,
filters,
(page - 1) * take,
take + 1,
cancellationToken);
var hasMore = messages.Count > take;
var dtos = messages
.Take(take)
.Select(message =>
{
message.Chat = chat;
return message.ToDto(presenceTracker, userId);
})
.ToList();
return new MessageSearchPageDto(dtos, page, take, hasMore);
}
private ChatSearchResultDto ToSearchResult(ChatSummaryDto summary, Message message, Guid userId)
{
var messageDto = message.ToDto(presenceTracker, userId);
return new ChatSearchResultDto(
summary,
messageDto,
BuildSearchMatchText(message),
message.SentAt,
summary.Id,
message.Id,
message.SenderId,
message.SentAt);
}
private async Task<List<Message>> LoadSearchMessagesAsync(
IReadOnlyCollection<Guid> chatIds,
string query,
SearchFilters filters,
int skip,
int limit,
CancellationToken cancellationToken)
{
if (chatIds.Count == 0)
{
return [];
}
var now = DateTimeOffset.UtcNow;
var messagesQuery = dbContext.Messages
.Where(message =>
chatIds.Contains(message.ChatId) &&
message.DeletedAt == null &&
(message.ExpiresAt == null || message.ExpiresAt > now));
if (!string.IsNullOrWhiteSpace(query))
{
var queryPattern = BuildLikePattern(query);
messagesQuery = messagesQuery.Where(message =>
EF.Functions.Like(message.Text, queryPattern, "\\") ||
message.Attachments.Any(attachment => EF.Functions.Like(attachment.OriginalFileName, queryPattern, "\\")));
}
if (filters.DateFrom is not null)
{
messagesQuery = messagesQuery.Where(message => message.SentAt >= filters.DateFrom.Value);
}
if (filters.DateTo is not null)
{
messagesQuery = messagesQuery.Where(message => message.SentAt <= filters.DateTo.Value);
}
if (filters.AuthorId is not null)
{
messagesQuery = messagesQuery.Where(message => message.SenderId == filters.AuthorId.Value);
}
if (!string.IsNullOrWhiteSpace(filters.AuthorQuery))
{
var authorPattern = BuildLikePattern(filters.AuthorQuery);
messagesQuery = messagesQuery.Where(message =>
EF.Functions.Like(message.Sender.DisplayName, authorPattern, "\\") ||
EF.Functions.Like(message.Sender.Username, authorPattern, "\\") ||
(message.Sender.PhoneNumber != null && EF.Functions.Like(message.Sender.PhoneNumber, authorPattern, "\\")));
}
messagesQuery = filters.Type switch
{
"text" => messagesQuery.Where(message => message.Text != ""),
"attachments" => messagesQuery.Where(message => message.Attachments.Any()),
"media" => messagesQuery.Where(message => message.Attachments.Any(attachment => attachment.ContentType.StartsWith("image/") || attachment.ContentType.StartsWith("video/"))),
"files" => messagesQuery.Where(message => message.Attachments.Any(attachment =>
!attachment.ContentType.StartsWith("image/") &&
!attachment.ContentType.StartsWith("video/") &&
!attachment.ContentType.StartsWith("audio/"))),
"links" => messagesQuery.Where(message => message.Text.Contains("http://") || message.Text.Contains("https://")),
"voice" => messagesQuery.Where(message => message.Attachments.Any(attachment => attachment.ContentType.StartsWith("audio/"))),
"pinned" => messagesQuery.Where(message => message.PinnedAt != null),
_ => messagesQuery
};
return await messagesQuery
.OrderByDescending(message => message.SentAt)
.Skip(skip)
.Take(limit)
.Include(message => message.Chat)
.ThenInclude(chat => chat.Members)
.ThenInclude(member => member.User)
.Include(message => message.Sender)
.Include(message => message.PinnedByUser)
.Include(message => message.Attachments)
.Include(message => message.Reactions)
.Include(message => message.Views)
.Include(message => message.ReplyToMessage)
.ThenInclude(message => message!.Sender)
.Include(message => message.ReplyToMessage)
.ThenInclude(message => message!.Attachments)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
private static bool MatchesChatSearch(Chat chat, string query)
{
if (!string.IsNullOrWhiteSpace(chat.Title) && chat.Title.Contains(query, StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (!string.IsNullOrWhiteSpace(chat.PublicUsername) &&
chat.PublicUsername.Contains(query, StringComparison.OrdinalIgnoreCase))
{
return true;
}
var usernameQuery = NormalizePublicSearchUsername(query);
if (!string.IsNullOrWhiteSpace(usernameQuery) &&
!string.IsNullOrWhiteSpace(chat.PublicUsername) &&
chat.PublicUsername.Contains(usernameQuery, StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (!string.IsNullOrWhiteSpace(chat.Description) &&
chat.Description.Contains(query, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return chat.Members.Any(member =>
member.User.DisplayName.Contains(query, StringComparison.OrdinalIgnoreCase) ||
member.User.Username.Contains(query, StringComparison.OrdinalIgnoreCase) ||
(member.User.PhoneNumber?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false));
}
private static bool MatchesPublicChannelSearch(Chat chat, string query)
{
if (!string.IsNullOrWhiteSpace(chat.Title) && chat.Title.Contains(query, StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (!string.IsNullOrWhiteSpace(chat.PublicUsername) &&
chat.PublicUsername.Contains(query, StringComparison.OrdinalIgnoreCase))
{
return true;
}
var usernameQuery = NormalizePublicSearchUsername(query);
if (!string.IsNullOrWhiteSpace(usernameQuery) &&
!string.IsNullOrWhiteSpace(chat.PublicUsername) &&
chat.PublicUsername.Contains(usernameQuery, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return !string.IsNullOrWhiteSpace(chat.Description) &&
chat.Description.Contains(query, StringComparison.OrdinalIgnoreCase);
}
private static string BuildSearchMatchText(Message message)
{
if (!string.IsNullOrWhiteSpace(message.Text))
{
return message.Text.Length <= 160 ? message.Text : message.Text[..160];
}
var attachment = message.Attachments
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.OriginalFileName)
.FirstOrDefault();
return attachment is null ? "Message" : attachment.OriginalFileName;
}
private static SearchFilters BuildSearchFilters(
string? legacyFilter,
string? type,
DateTimeOffset? date,
DateTimeOffset? dateFrom,
DateTimeOffset? dateTo,
Guid? authorId,
string? author)
{
var normalizedType = NormalizeSearchType(type) ?? NormalizeSearchType(legacyFilter);
var effectiveDateFrom = dateFrom;
var effectiveDateTo = dateTo;
if (date is not null)
{
var dateStart = new DateTimeOffset(date.Value.Date, date.Value.Offset);
effectiveDateFrom ??= dateStart;
effectiveDateTo ??= dateStart.AddDays(1).AddTicks(-1);
}
return new SearchFilters(
normalizedType,
effectiveDateFrom,
effectiveDateTo,
authorId,
string.IsNullOrWhiteSpace(author) ? null : author.Trim());
}
private static string? NormalizeSearchType(string? value)
{
var normalized = value?.Trim().ToLowerInvariant();
return normalized switch
{
"attachment" or "attachments" => "attachments",
"file" or "files" or "document" or "documents" => "files",
"image" or "images" or "video" or "videos" or "media" => "media",
"link" or "links" or "url" or "urls" => "links",
"audio" or "voice" or "voice-note" or "voice_note" or "voice-notes" or "voice_notes" => "voice",
"pin" or "pinned" or "pinned-message" or "pinned_message" or "pinned-messages" or "pinned_messages" => "pinned",
"message" or "messages" or "text" => "text",
_ => null
};
}
private static int NormalizeSearchPage(int page) => Math.Clamp(page, 1, 10_000);
private static int NormalizeSearchLimit(int limit) => Math.Clamp(limit, 1, 100);
private static string BuildLikePattern(string value)
{
var escaped = value
.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("%", "\\%", StringComparison.Ordinal)
.Replace("_", "\\_", StringComparison.Ordinal);
return $"%{escaped}%";
}
private static string? NormalizePublicSearchUsername(string value)
{
var normalized = value.Trim().TrimStart('@');
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
}
private void WriteSearchPageHeaders(int page, int limit, bool hasMore)
{
Response.Headers["X-Search-Page"] = page.ToString(System.Globalization.CultureInfo.InvariantCulture);
Response.Headers["X-Search-Limit"] = limit.ToString(System.Globalization.CultureInfo.InvariantCulture);
Response.Headers["X-Search-Has-More"] = hasMore ? "true" : "false";
}
private sealed record SearchFilters(
string? Type,
DateTimeOffset? DateFrom,
DateTimeOffset? DateTo,
Guid? AuthorId,
string? AuthorQuery)
{
public bool HasMessageFilter =>
Type is not null ||
DateFrom is not null ||
DateTo is not null ||
AuthorId is not null ||
!string.IsNullOrWhiteSpace(AuthorQuery);
}
private static string? NormalizeFolderKey(string? value)
{
var normalized = value?.Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(normalized))
{
return null;
}
return new string(normalized
.Where(ch => char.IsLetterOrDigit(ch) || ch is '-' or '_')
.Take(48)
.ToArray());
}
private static string? NormalizePublicUsername(string? value)
{
var trimmed = value?.Trim().TrimStart('@');
if (string.IsNullOrWhiteSpace(trimmed))
{
return null;
}
var normalized = new string(trimmed
.Where(ch => ch is >= 'a' and <= 'z' || ch is >= 'A' and <= 'Z' || char.IsDigit(ch) || ch == '_')
.Take(32)
.ToArray())
.ToLowerInvariant();
if (normalized.Length < 5)
{
throw new InvalidOperationException("Public username must contain at least 5 characters.");
}
return normalized;
}
private ChatInviteLinkDto ToInviteLinkDto(ChatInviteLink link) =>
new(
link.Id,
link.ChatId,
link.Token,
$"{Request.Scheme}://{Request.Host}/join/{Uri.EscapeDataString(link.Token)}",
link.Title,
link.CreatesJoinRequest,
link.IsRevoked,
link.CreatedAt,
link.RevokedAt,
link.ExpiresAt,
link.MaxUses,
link.UsesCount,
link.MemberLimit);
private static string? ValidateInviteAvailability(ChatInviteLink invite, DateTimeOffset now, int currentMemberCount)
{
if (invite.ExpiresAt is not null && invite.ExpiresAt <= now)
{
return "Invite link has expired.";
}
if (invite.MaxUses is not null && invite.UsesCount >= invite.MaxUses.Value)
{
return "Invite link usage limit reached.";
}
if (invite.MemberLimit is not null && currentMemberCount >= invite.MemberLimit.Value)
{
return "Chat member limit reached.";
}
return null;
}
private static DateTimeOffset? NormalizeInviteExpiresAt(DateTimeOffset? expiresAt)
{
if (expiresAt is null)
{
return null;
}
if (expiresAt.Value <= DateTimeOffset.UtcNow)
{
throw new InvalidOperationException("Invite link expiration must be in the future.");
}
return expiresAt.Value;
}
private static int? NormalizeInviteLimit(int? value, string fieldName)
{
if (value is null)
{
return null;
}
if (value.Value <= 0)
{
throw new InvalidOperationException($"{fieldName} must be greater than zero.");
}
return value.Value;
}
private ChatJoinRequestDto ToJoinRequestDto(ChatJoinRequest request) =>
new(
request.Id,
request.ChatId,
request.InviteLinkId,
request.User.ToDto(presenceTracker),
request.Status,
request.RequestedAt,
request.ResolvedAt,
request.ResolvedByUser?.ToDto(presenceTracker));
private async Task AddModerationLogAsync(
Guid actorUserId,
Guid? chatId,
ModerationLogTargetType targetType,
Guid? targetId,
string action,
string? reason,
CancellationToken cancellationToken)
{
var actorExists = await dbContext.Users.AnyAsync(x => x.Id == actorUserId, cancellationToken);
if (!actorExists)
{
return;
}
dbContext.ModerationLogEntries.Add(new ModerationLogEntry
{
ActorUserId = actorUserId,
ChatId = chatId,
TargetType = targetType,
TargetId = targetId,
Action = action,
Reason = TrimToNull(reason, 256),
CreatedAt = DateTimeOffset.UtcNow
});
}
private static int? NormalizeTtlSeconds(int? ttlSeconds, bool allowNull)
{
if (ttlSeconds is null || ttlSeconds <= 0)
{
return allowNull ? null : 86400;
}
return Math.Clamp(ttlSeconds.Value, 5, 31 * 24 * 60 * 60);
}
private async Task<bool> IsBlockedByPrivacyAsync(Guid ownerUserId, Guid candidateUserId, CancellationToken cancellationToken)
{
var rawBlocked = await dbContext.UserPrivacySettings
.AsNoTracking()
.Where(x => x.UserId == ownerUserId)
.Select(x => x.BlockedUserIds)
.SingleOrDefaultAsync(cancellationToken);
return ContainsSerializedUserId(rawBlocked, candidateUserId);
}
private static bool ContainsSerializedUserId(string? value, Guid userId) =>
!string.IsNullOrWhiteSpace(value) &&
value
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Any(x => Guid.TryParse(x, out var parsed) && parsed == userId);
private async Task<string> GenerateInviteTokenAsync(CancellationToken cancellationToken)
{
while (true)
{
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(18))
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
var exists = await dbContext.ChatInviteLinks
.AnyAsync(x => x.Token == token, cancellationToken);
if (!exists)
{
return token;
}
}
}
private static string? TrimToNull(string? value, int maxLength)
{
var trimmed = value?.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
{
return null;
}
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength];
}
private async Task<List<ChatTopicDto>> LoadChatTopicDtosAsync(Guid chatId, CancellationToken cancellationToken)
{
var messageCounts = await dbContext.Messages
.AsNoTracking()
.Where(x => x.ChatId == chatId && x.TopicId != null && x.DeletedAt == null)
.GroupBy(x => x.TopicId!.Value)
.Select(group => new { TopicId = group.Key, Count = group.Count() })
.ToDictionaryAsync(x => x.TopicId, x => x.Count, cancellationToken);
var topics = await dbContext.ChatTopics
.AsNoTracking()
.Where(x => x.ChatId == chatId)
.OrderByDescending(x => x.IsPinned)
.ThenByDescending(x => x.LastActivityAt)
.ToListAsync(cancellationToken);
return topics
.Select(x => new ChatTopicDto(
x.Id,
x.ChatId,
x.Title,
x.Icon,
x.IsPinned,
x.IsClosed,
x.CreatedAt,
x.LastActivityAt,
messageCounts.GetValueOrDefault(x.Id)))
.ToList();
}
private static ChatPermissionsDto ResolvePermissions(Chat chat, Guid userId) =>
DtoMapper.BuildPermissions(chat.Members.FirstOrDefault(member => member.UserId == userId), chat.Type);
private static bool CanManageMembers(Chat chat, Guid userId) =>
ResolvePermissions(chat, userId).CanManageMembers;
private static bool CanUserPublish(Chat chat, Guid userId) =>
ResolvePermissions(chat, userId).CanPostMessages;
private static bool CanEditMessage(Message message, Guid userId) =>
message.SenderId == userId ||
(message.Chat.Type == ChatType.Channel && ResolvePermissions(message.Chat, userId).CanPostMessages);
private static bool ShouldPostAsChannel(Chat chat) =>
chat.Type == ChatType.Channel;
private static bool IsMediaAlbumFile(IFormFile file)
{
var contentInfo = AttachmentContentInspector.Inspect(file);
return contentInfo.IsImage || contentInfo.IsVideo;
}
private static string? ResolveChannelAuthorSignature(Chat chat, User sender)
{
if (chat.Type != ChatType.Channel || !chat.ChannelSignaturesEnabled)
{
return null;
}
return string.IsNullOrWhiteSpace(sender.DisplayName) ? sender.Username : sender.DisplayName;
}
private static ChatMember CreateSubscriberMembership(Chat chat, Guid userId)
{
var isChannel = chat.Type == ChatType.Channel;
return new ChatMember
{
ChatId = chat.Id,
UserId = userId,
Role = ChatMemberRole.Member,
CanPinMessages = !isChannel,
CanDeleteMessages = false,
CanInviteUsers = !isChannel,
CanPostMessages = !isChannel,
JoinedAt = DateTimeOffset.UtcNow
};
}
private static string ResolveForwardedFromDisplayName(Message sourceMessage)
{
var forwardedFromDisplayName = sourceMessage.ForwardedFromDisplayName;
if (!string.IsNullOrWhiteSpace(forwardedFromDisplayName))
{
return forwardedFromDisplayName;
}
if (sourceMessage.Chat.Type == ChatType.Channel && sourceMessage.PostedAsChannel)
{
return string.IsNullOrWhiteSpace(sourceMessage.Chat.Title) ? "Channel" : sourceMessage.Chat.Title!;
}
return string.IsNullOrWhiteSpace(sourceMessage.Sender.DisplayName)
? sourceMessage.Sender.Username
: sourceMessage.Sender.DisplayName;
}
private async Task NotifyMessageCreatedAsync(Chat chat, Message message, CancellationToken cancellationToken)
{
foreach (var member in chat.Members)
{
var dto = message.ToDto(presenceTracker, member.UserId);
await hubContext.Clients.User(member.UserId.ToString())
.SendAsync("MessageCreated", dto, cancellationToken);
}
await NotifyChatsChangedAsync(chat.Members.Select(member => member.UserId), cancellationToken);
}
private async Task NotifyMessageUpdatedAsync(Chat chat, IReadOnlyCollection<Message> messages, CancellationToken cancellationToken)
{
foreach (var member in chat.Members)
{
foreach (var message in messages.OrderBy(x => x.SentAt))
{
var dto = message.ToDto(presenceTracker, member.UserId);
await hubContext.Clients.User(member.UserId.ToString())
.SendAsync("MessageUpdated", dto, cancellationToken);
}
}
}
private async Task NotifyChatCreatedAsync(Chat chat, CancellationToken cancellationToken)
{
foreach (var member in chat.Members)
{
var summary = chat.ToSummaryDto(member.UserId, presenceTracker);
await hubContext.Clients.User(member.UserId.ToString())
.SendAsync("ChatCreated", summary, cancellationToken);
}
await hubContext.Clients.Users(chat.Members.Select(x => x.UserId.ToString()))
.SendAsync("ChatsChanged", cancellationToken);
}
private Task NotifyChatsChangedAsync(IEnumerable<Guid> userIds, CancellationToken cancellationToken) =>
hubContext.Clients.Users(userIds.Select(x => x.ToString()).Distinct())
.SendAsync("ChatsChanged", cancellationToken);
private void DeleteFiles(IEnumerable<string> attachmentPaths)
{
foreach (var path in attachmentPaths)
{
attachmentStorageService.DeleteIfExists(path);
}
}
}