4567 lines
173 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|