1039 lines
37 KiB
C#
1039 lines
37 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.SignalR;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using QMax.Api.Contracts;
|
|
using QMax.Api.Data;
|
|
using QMax.Api.Data.Entities;
|
|
using QMax.Api.Infrastructure.Hubs;
|
|
using QMax.Api.Infrastructure.Max;
|
|
using QMax.Api.Infrastructure.Storage;
|
|
using QMax.Api.Services;
|
|
|
|
namespace QMax.Api.Controllers;
|
|
|
|
[ApiController]
|
|
[Authorize]
|
|
[Route("api/chats")]
|
|
public sealed class ChatsController(
|
|
QMaxDbContext db,
|
|
ChatProjectionService projection,
|
|
IMaxBridgeClient maxBridge,
|
|
IAttachmentStorageService storage,
|
|
IHubContext<QMaxHub> hubContext,
|
|
MaxBridgeSyncService syncService) : ControllerBase
|
|
{
|
|
[HttpGet]
|
|
public async Task<ActionResult<IReadOnlyList<ChatDto>>> GetChats(CancellationToken cancellationToken)
|
|
{
|
|
var chats = (await db.Chats
|
|
.Where(x => x.DeletedAt == null)
|
|
.ToArrayAsync(cancellationToken))
|
|
.OrderByDescending(x => x.IsPinned)
|
|
.ThenByDescending(x => x.UnreadCount > 0)
|
|
.ThenByDescending(x => x.LastMessageAt ?? x.UpdatedAt)
|
|
.ToArray();
|
|
|
|
return chats.Select(projection.ToDto).ToArray();
|
|
}
|
|
|
|
[HttpPost("direct")]
|
|
public async Task<ActionResult<ChatDto>> CreateDirect(CreateDirectChatRequest request, CancellationToken cancellationToken)
|
|
{
|
|
var chat = await db.Chats.FirstOrDefaultAsync(x => x.ExternalId == request.ExternalChatId, cancellationToken);
|
|
if (chat is null)
|
|
{
|
|
chat = new Chat
|
|
{
|
|
ExternalId = request.ExternalChatId,
|
|
Title = request.Title,
|
|
AvatarUrl = request.AvatarUrl,
|
|
Kind = ChatKind.MaxDialog
|
|
};
|
|
db.Chats.Add(chat);
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
|
return projection.ToDto(chat);
|
|
}
|
|
|
|
[HttpGet("{chatId:guid}/messages")]
|
|
public async Task<ActionResult<IReadOnlyList<MessageDto>>> GetMessages(
|
|
Guid chatId,
|
|
int take = 80,
|
|
DateTimeOffset? before = null,
|
|
bool sync = false,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId && x.DeletedAt == null, cancellationToken);
|
|
if (chat is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
if (sync && before is null && !string.IsNullOrWhiteSpace(chat.ExternalId))
|
|
{
|
|
await syncService.SyncChatHistoryAsync(chat.ExternalId, chat.WebUrl, cancellationToken);
|
|
db.ChangeTracker.Clear();
|
|
chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId && x.DeletedAt == null, cancellationToken);
|
|
if (chat is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
}
|
|
|
|
if (before is null)
|
|
{
|
|
await EnsureLastPreviewMessageAsync(chat, cancellationToken);
|
|
}
|
|
|
|
if (before is null && chat.UnreadCount > 0)
|
|
{
|
|
chat.UnreadCount = 0;
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
|
}
|
|
|
|
var query = db.Messages
|
|
.Include(x => x.Attachments)
|
|
.Include(x => x.Reactions)
|
|
.Where(x => x.ChatId == chatId && x.DeletedAt == null);
|
|
|
|
if (before is not null)
|
|
{
|
|
query = query.Where(x => x.SentAt < before);
|
|
}
|
|
|
|
var messages = (await query.ToArrayAsync(cancellationToken))
|
|
.Where(message => !IsGenericPreviewPlaceholder(message))
|
|
.OrderByDescending(x => x.SentAt)
|
|
.Take(Math.Clamp(take, 1, 200))
|
|
.ToArray();
|
|
|
|
return messages.OrderBy(x => x.SentAt).Select(projection.ToDto).ToArray();
|
|
}
|
|
|
|
private async Task EnsureLastPreviewMessageAsync(Chat chat, CancellationToken cancellationToken)
|
|
{
|
|
var preview = MessageTextSanitizer.CleanChatPreview(chat.LastMessagePreview);
|
|
if (string.IsNullOrWhiteSpace(preview) || chat.LastMessageAt is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (MessageTextSanitizer.IsGenericMediaLabel(preview))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var previewAt = chat.LastMessageAt.Value;
|
|
if (chat.HistoryClearedAt is { } historyClearedAt && previewAt <= historyClearedAt)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var latestMessage = (await db.Messages
|
|
.AsNoTracking()
|
|
.Where(x => x.ChatId == chat.Id && x.DeletedAt == null)
|
|
.ToArrayAsync(cancellationToken))
|
|
.OrderByDescending(x => x.SentAt)
|
|
.FirstOrDefault();
|
|
if (latestMessage is not null && latestMessage.SentAt >= previewAt.AddSeconds(-1))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var externalId = PreviewExternalId(chat.Id, preview, previewAt);
|
|
if (await HasPreviewPlaceholderAsync(chat.Id, externalId, preview, previewAt, cancellationToken))
|
|
{
|
|
return;
|
|
}
|
|
|
|
db.Messages.Add(new Message
|
|
{
|
|
ChatId = chat.Id,
|
|
ExternalId = externalId,
|
|
Direction = MessageDirection.Incoming,
|
|
Text = preview,
|
|
SentAt = previewAt,
|
|
DeliveryState = MessageDeliveryState.Delivered,
|
|
SenderExternalId = chat.ExternalId,
|
|
SenderName = chat.Title
|
|
});
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
private async Task<bool> HasPreviewPlaceholderAsync(
|
|
Guid chatId,
|
|
string externalId,
|
|
string preview,
|
|
DateTimeOffset previewAt,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var previewMessages = await db.Messages
|
|
.AsNoTracking()
|
|
.Where(x =>
|
|
x.ChatId == chatId &&
|
|
x.DeletedAt == null &&
|
|
x.Direction == MessageDirection.Incoming &&
|
|
x.ExternalId != null &&
|
|
(x.ExternalId == externalId || x.ExternalId.StartsWith("preview:")))
|
|
.ToArrayAsync(cancellationToken);
|
|
|
|
return previewMessages.Any(message =>
|
|
string.Equals(message.ExternalId, externalId, StringComparison.Ordinal) ||
|
|
string.Equals(
|
|
MessageTextSanitizer.CleanMessageText(message.Text, false),
|
|
preview,
|
|
StringComparison.Ordinal) &&
|
|
Math.Abs((message.SentAt.ToUniversalTime() - previewAt.ToUniversalTime()).TotalMinutes) <= 30);
|
|
}
|
|
|
|
private static string PreviewExternalId(Guid chatId, string preview, DateTimeOffset previewAt)
|
|
{
|
|
var previewMinute = previewAt.ToUniversalTime().Ticks / TimeSpan.TicksPerMinute;
|
|
return $"preview:{chatId:N}:{previewMinute}:{StableHash(preview.Trim().ToLowerInvariant())}";
|
|
}
|
|
|
|
private static string StableHash(string value)
|
|
{
|
|
var hash = 2166136261u;
|
|
foreach (var character in value)
|
|
{
|
|
hash ^= character;
|
|
hash *= 16777619;
|
|
}
|
|
|
|
return hash.ToString("x8");
|
|
}
|
|
|
|
private static bool IsGenericPreviewPlaceholder(Message message)
|
|
{
|
|
return message.Direction == MessageDirection.Incoming &&
|
|
message.Attachments.Count == 0 &&
|
|
message.ExternalId?.StartsWith("preview:", StringComparison.Ordinal) == true &&
|
|
MessageTextSanitizer.IsGenericMediaLabel(message.Text);
|
|
}
|
|
|
|
[HttpPost("{chatId:guid}/read")]
|
|
public async Task<IActionResult> MarkRead(Guid chatId, CancellationToken cancellationToken)
|
|
{
|
|
var chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId && x.DeletedAt == null, cancellationToken);
|
|
if (chat is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
if (chat.UnreadCount > 0)
|
|
{
|
|
chat.UnreadCount = 0;
|
|
chat.UpdatedAt = DateTimeOffset.UtcNow;
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
|
}
|
|
|
|
return NoContent();
|
|
}
|
|
|
|
[HttpPost("clear-history")]
|
|
public async Task<IActionResult> ClearHistories(ChatBulkActionRequest request, CancellationToken cancellationToken)
|
|
{
|
|
var chatIds = request.ChatIds.Distinct().ToArray();
|
|
if (chatIds.Length == 0)
|
|
{
|
|
return BadRequest("chatIds are required.");
|
|
}
|
|
|
|
var chats = await LoadChatsWithAttachmentsAsync(chatIds, cancellationToken);
|
|
if (chats.Count != chatIds.Length)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var now = DateTimeOffset.UtcNow;
|
|
var files = AttachmentFiles(chats).ToArray();
|
|
foreach (var chat in chats)
|
|
{
|
|
db.Messages.RemoveRange(chat.Messages);
|
|
chat.LastMessageAt = null;
|
|
chat.LastMessagePreview = null;
|
|
chat.UnreadCount = 0;
|
|
chat.HistoryClearedAt = now;
|
|
chat.UpdatedAt = now;
|
|
if (chat.DeletedAt is null)
|
|
{
|
|
QueuePendingMaxAction(chat, ChatPendingMaxAction.ClearHistory, now);
|
|
}
|
|
}
|
|
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
DeleteFiles(files);
|
|
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
|
return NoContent();
|
|
}
|
|
|
|
[HttpPost("delete")]
|
|
public IActionResult DeleteChats(ChatBulkActionRequest request)
|
|
{
|
|
var chatIds = request.ChatIds.Distinct().ToArray();
|
|
if (chatIds.Length == 0)
|
|
{
|
|
return BadRequest("chatIds are required.");
|
|
}
|
|
|
|
return BadRequest("Chat deletion is disabled because MAX does not remove chats from the web client.");
|
|
}
|
|
|
|
[HttpGet("{chatId:guid}/search")]
|
|
public async Task<ActionResult<IReadOnlyList<MessageDto>>> SearchMessages(
|
|
Guid chatId,
|
|
[FromQuery(Name = "q")] string? query,
|
|
int take = 50,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var term = query?.Trim();
|
|
if (string.IsNullOrWhiteSpace(term))
|
|
{
|
|
return BadRequest("Search query is required.");
|
|
}
|
|
|
|
var chatExists = await db.Chats.AnyAsync(x => x.Id == chatId && x.DeletedAt == null, cancellationToken);
|
|
if (!chatExists)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var messages = await db.Messages
|
|
.Include(x => x.Attachments)
|
|
.Include(x => x.Reactions)
|
|
.Where(x => x.ChatId == chatId && x.DeletedAt == null)
|
|
.ToArrayAsync(cancellationToken);
|
|
|
|
var results = messages
|
|
.Where(x => MatchesSearch(x, term))
|
|
.OrderByDescending(x => x.SentAt)
|
|
.Take(Math.Clamp(take, 1, 200))
|
|
.OrderBy(x => x.SentAt)
|
|
.Select(projection.ToDto)
|
|
.ToArray();
|
|
|
|
return results;
|
|
}
|
|
|
|
[HttpGet("{chatId:guid}/avatar")]
|
|
public async Task<IActionResult> DownloadAvatar(Guid chatId, CancellationToken cancellationToken)
|
|
{
|
|
var chat = await db.Chats
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(x => x.Id == chatId && x.DeletedAt == null, cancellationToken);
|
|
|
|
if (chat is null || string.IsNullOrWhiteSpace(chat.AvatarUrl))
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var remote = await maxBridge.DownloadMediaAsync(chat.AvatarUrl, cancellationToken);
|
|
if (remote is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
return File(remote.Stream, remote.ContentType);
|
|
}
|
|
|
|
[HttpGet("{chatId:guid}/presence")]
|
|
public async Task<ActionResult<ChatPresenceDto>> GetPresence(Guid chatId, CancellationToken cancellationToken)
|
|
{
|
|
var chat = await db.Chats
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(x => x.Id == chatId && x.DeletedAt == null, cancellationToken);
|
|
|
|
if (chat is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(chat.ExternalId))
|
|
{
|
|
return new ChatPresenceDto(false, null, DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
var presence = await maxBridge.FetchChatPresenceAsync(chat.ExternalId, chat.WebUrl, cancellationToken);
|
|
return new ChatPresenceDto(
|
|
presence?.IsTyping == true,
|
|
presence?.StatusText,
|
|
presence?.UpdatedAt ?? DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
[HttpPost("{chatId:guid}/messages")]
|
|
public async Task<ActionResult<MessageDto>> SendMessage(Guid chatId, SendMessageRequest request, CancellationToken cancellationToken)
|
|
{
|
|
var chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId && x.DeletedAt == null, cancellationToken);
|
|
if (chat is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var text = (request.Text ?? "").Trim();
|
|
if (string.IsNullOrWhiteSpace(text))
|
|
{
|
|
return BadRequest("Message text is required.");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(chat.ExternalId))
|
|
{
|
|
return BadRequest("Chat is not linked to MAX.");
|
|
}
|
|
|
|
var message = new Message
|
|
{
|
|
ChatId = chat.Id,
|
|
Direction = MessageDirection.Outgoing,
|
|
Text = text,
|
|
ReplyToMessageId = request.ReplyToMessageId,
|
|
SentAt = DateTimeOffset.UtcNow,
|
|
DeliveryState = MessageDeliveryState.Sending,
|
|
SenderName = "You"
|
|
};
|
|
db.Messages.Add(message);
|
|
chat.LastMessagePreview = text;
|
|
chat.LastMessageAt = message.SentAt;
|
|
chat.UpdatedAt = DateTimeOffset.UtcNow;
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
|
|
var dto = projection.ToDto(message);
|
|
await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken);
|
|
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
|
return dto;
|
|
}
|
|
|
|
[HttpPatch("{chatId:guid}/messages/{messageId:guid}")]
|
|
public async Task<ActionResult<MessageDto>> EditMessage(
|
|
Guid chatId,
|
|
Guid messageId,
|
|
EditMessageRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var text = request.Text.Trim();
|
|
if (string.IsNullOrWhiteSpace(text))
|
|
{
|
|
return BadRequest("Message text is required.");
|
|
}
|
|
|
|
var message = await db.Messages
|
|
.Include(x => x.Chat)
|
|
.Include(x => x.Attachments)
|
|
.Include(x => x.Reactions)
|
|
.FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken);
|
|
if (message is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
if (message.Direction != MessageDirection.Outgoing)
|
|
{
|
|
return Forbid();
|
|
}
|
|
|
|
var maxFailure = await TryApplyMaxActionAsync(
|
|
message,
|
|
(externalChatId, externalMessageId) => maxBridge.EditMessageAsync(externalChatId, externalMessageId, message.Text, text, cancellationToken),
|
|
"edit",
|
|
cancellationToken);
|
|
if (maxFailure is not null)
|
|
{
|
|
return maxFailure;
|
|
}
|
|
|
|
message.Text = text;
|
|
message.EditedAt = DateTimeOffset.UtcNow;
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
|
|
if (message.Chat is not null)
|
|
{
|
|
await RecalculateChatPreviewAsync(message.Chat, cancellationToken);
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
var dto = projection.ToDto(message);
|
|
await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageUpdated", dto, cancellationToken);
|
|
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
|
return dto;
|
|
}
|
|
|
|
[HttpDelete("{chatId:guid}/messages/{messageId:guid}")]
|
|
public async Task<IActionResult> DeleteMessage(Guid chatId, Guid messageId, CancellationToken cancellationToken)
|
|
{
|
|
var message = await db.Messages
|
|
.Include(x => x.Chat)
|
|
.FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken);
|
|
if (message is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var maxFailure = await TryApplyMaxActionAsync(
|
|
message,
|
|
(externalChatId, externalMessageId) => maxBridge.DeleteMessageAsync(externalChatId, externalMessageId, message.Text, cancellationToken),
|
|
"delete",
|
|
cancellationToken);
|
|
if (maxFailure is not null)
|
|
{
|
|
return maxFailure;
|
|
}
|
|
|
|
var deletedAt = DateTimeOffset.UtcNow;
|
|
message.DeletedAt = deletedAt;
|
|
message.EditedAt = message.EditedAt ?? deletedAt;
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
|
|
if (message.Chat is not null)
|
|
{
|
|
await RecalculateChatPreviewAsync(message.Chat, cancellationToken);
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageDeleted", new MessageDeletedDto(chatId, messageId), cancellationToken);
|
|
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
|
return NoContent();
|
|
}
|
|
|
|
[HttpPut("{chatId:guid}/messages/{messageId:guid}/reaction")]
|
|
public async Task<ActionResult<MessageDto>> SetReaction(
|
|
Guid chatId,
|
|
Guid messageId,
|
|
SetReactionRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var emoji = NormalizeReaction(request.Emoji);
|
|
if (emoji is null)
|
|
{
|
|
return BadRequest("Reaction is required.");
|
|
}
|
|
|
|
var message = await db.Messages
|
|
.Include(x => x.Chat)
|
|
.Include(x => x.Attachments)
|
|
.Include(x => x.Reactions)
|
|
.FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken);
|
|
if (message is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var maxFailure = await TryApplyMaxActionAsync(
|
|
message,
|
|
(externalChatId, externalMessageId) => maxBridge.SetReactionAsync(externalChatId, externalMessageId, message.Text, emoji, cancellationToken),
|
|
"reaction",
|
|
cancellationToken);
|
|
if (maxFailure is not null)
|
|
{
|
|
return maxFailure;
|
|
}
|
|
|
|
var reaction = message.Reactions.FirstOrDefault(x => x.ActorKey == "self");
|
|
if (reaction is null)
|
|
{
|
|
reaction = new MessageReaction
|
|
{
|
|
MessageId = message.Id,
|
|
Emoji = emoji,
|
|
ActorKey = "self",
|
|
ActorName = "You"
|
|
};
|
|
db.MessageReactions.Add(reaction);
|
|
}
|
|
else
|
|
{
|
|
reaction.Emoji = emoji;
|
|
reaction.ActorName = "You";
|
|
reaction.CreatedAt = DateTimeOffset.UtcNow;
|
|
}
|
|
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
var updated = await LoadMessageForProjectionAsync(chatId, messageId, cancellationToken) ?? message;
|
|
var dto = projection.ToDto(updated);
|
|
await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageUpdated", dto, cancellationToken);
|
|
return dto;
|
|
}
|
|
|
|
[HttpDelete("{chatId:guid}/messages/{messageId:guid}/reaction")]
|
|
public async Task<ActionResult<MessageDto>> ClearReaction(Guid chatId, Guid messageId, CancellationToken cancellationToken)
|
|
{
|
|
var message = await db.Messages
|
|
.Include(x => x.Chat)
|
|
.Include(x => x.Attachments)
|
|
.Include(x => x.Reactions)
|
|
.FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken);
|
|
if (message is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var reaction = message.Reactions.FirstOrDefault(x => x.ActorKey == "self");
|
|
if (reaction is not null)
|
|
{
|
|
var maxFailure = await TryApplyMaxActionAsync(
|
|
message,
|
|
(externalChatId, externalMessageId) => maxBridge.ClearReactionAsync(externalChatId, externalMessageId, message.Text, reaction.Emoji, cancellationToken),
|
|
"reaction clear",
|
|
cancellationToken);
|
|
if (maxFailure is not null)
|
|
{
|
|
return maxFailure;
|
|
}
|
|
|
|
message.Reactions.Remove(reaction);
|
|
db.MessageReactions.Remove(reaction);
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
var updated = await LoadMessageForProjectionAsync(chatId, messageId, cancellationToken) ?? message;
|
|
var dto = projection.ToDto(updated);
|
|
await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageUpdated", dto, cancellationToken);
|
|
return dto;
|
|
}
|
|
|
|
[HttpPost("{chatId:guid}/messages/{messageId:guid}/forward")]
|
|
public async Task<ActionResult<MessageDto>> ForwardMessage(
|
|
Guid chatId,
|
|
Guid messageId,
|
|
ForwardMessageRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var source = await db.Messages
|
|
.Include(x => x.Chat)
|
|
.Include(x => x.Attachments)
|
|
.FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken);
|
|
if (source is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var target = await db.Chats.FirstOrDefaultAsync(x => x.Id == request.TargetChatId && x.DeletedAt == null, cancellationToken);
|
|
if (target is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(target.ExternalId))
|
|
{
|
|
return BadRequest("Target chat is not linked to MAX.");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(source.Text) && source.Attachments.Count == 0)
|
|
{
|
|
return BadRequest("Cannot forward an empty message.");
|
|
}
|
|
|
|
var message = new Message
|
|
{
|
|
ChatId = target.Id,
|
|
Direction = MessageDirection.Outgoing,
|
|
Text = source.Text,
|
|
ForwardedFrom = ResolveForwardedFrom(source),
|
|
SentAt = DateTimeOffset.UtcNow,
|
|
DeliveryState = MessageDeliveryState.Sending,
|
|
SenderName = "You"
|
|
};
|
|
|
|
var sortOrder = 0;
|
|
foreach (var attachment in source.Attachments.OrderBy(x => x.SortOrder))
|
|
{
|
|
StoredAttachment stored;
|
|
try
|
|
{
|
|
stored = await CopyForwardAttachmentAsync(attachment, cancellationToken);
|
|
}
|
|
catch (InvalidOperationException error)
|
|
{
|
|
return Problem(error.Message, statusCode: StatusCodes.Status409Conflict);
|
|
}
|
|
|
|
message.Attachments.Add(new MessageAttachment
|
|
{
|
|
OriginalFileName = stored.OriginalFileName,
|
|
StorageFileName = stored.StorageFileName,
|
|
ContentType = stored.ContentType,
|
|
FileSizeBytes = stored.FileSizeBytes,
|
|
Sha256 = stored.Sha256,
|
|
Kind = stored.Kind,
|
|
SortOrder = sortOrder++
|
|
});
|
|
}
|
|
|
|
db.Messages.Add(message);
|
|
target.LastMessagePreview = ForwardPreview(message);
|
|
target.LastMessageAt = message.SentAt;
|
|
target.UpdatedAt = DateTimeOffset.UtcNow;
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
|
|
var dto = projection.ToDto(message);
|
|
await hubContext.Clients.Group($"chat:{target.Id}").SendAsync("MessageCreated", dto, cancellationToken);
|
|
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
|
return dto;
|
|
}
|
|
|
|
[HttpPost("{chatId:guid}/attachments")]
|
|
[RequestSizeLimit(30L * 1024 * 1024)]
|
|
public async Task<ActionResult<MessageDto>> UploadAttachment(
|
|
Guid chatId,
|
|
[FromForm] IFormFile file,
|
|
[FromForm] string? caption,
|
|
[FromForm] Guid? replyToMessageId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId && x.DeletedAt == null, cancellationToken);
|
|
if (chat is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(chat.ExternalId))
|
|
{
|
|
return BadRequest("Chat is not linked to MAX.");
|
|
}
|
|
|
|
var stored = await storage.SaveAsync(file, cancellationToken);
|
|
|
|
var message = new Message
|
|
{
|
|
ChatId = chat.Id,
|
|
Direction = MessageDirection.Outgoing,
|
|
Text = caption,
|
|
ReplyToMessageId = replyToMessageId,
|
|
SentAt = DateTimeOffset.UtcNow,
|
|
DeliveryState = MessageDeliveryState.Sending,
|
|
SenderName = "You",
|
|
Attachments =
|
|
[
|
|
new MessageAttachment
|
|
{
|
|
OriginalFileName = stored.OriginalFileName,
|
|
StorageFileName = stored.StorageFileName,
|
|
ContentType = stored.ContentType,
|
|
FileSizeBytes = stored.FileSizeBytes,
|
|
Sha256 = stored.Sha256,
|
|
Kind = stored.Kind
|
|
}
|
|
]
|
|
};
|
|
|
|
db.Messages.Add(message);
|
|
chat.LastMessagePreview = !string.IsNullOrWhiteSpace(caption)
|
|
? caption
|
|
: stored.Kind switch
|
|
{
|
|
AttachmentKind.Image => "\u041c\u0435\u0434\u0438\u0430",
|
|
AttachmentKind.Gif => "\u041c\u0435\u0434\u0438\u0430",
|
|
AttachmentKind.Video => "\u041c\u0435\u0434\u0438\u0430",
|
|
AttachmentKind.VoiceNote => "\u0413\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435",
|
|
AttachmentKind.Sticker => "\u041c\u0435\u0434\u0438\u0430",
|
|
AttachmentKind.Contact => "\u041a\u043e\u043d\u0442\u0430\u043a\u0442",
|
|
_ => stored.OriginalFileName
|
|
};
|
|
chat.LastMessageAt = message.SentAt;
|
|
chat.UpdatedAt = DateTimeOffset.UtcNow;
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
|
|
var dto = projection.ToDto(message);
|
|
await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken);
|
|
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
|
return dto;
|
|
}
|
|
|
|
[HttpGet("{chatId:guid}/attachments/{attachmentId:guid}/content")]
|
|
public async Task<IActionResult> DownloadAttachment(Guid chatId, Guid attachmentId, CancellationToken cancellationToken)
|
|
{
|
|
var attachment = await db.MessageAttachments
|
|
.Include(x => x.Message)
|
|
.FirstOrDefaultAsync(x => x.Id == attachmentId && x.Message!.ChatId == chatId, cancellationToken);
|
|
|
|
if (attachment is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var path = storage.GetPath(attachment.StorageFileName);
|
|
if (!System.IO.File.Exists(path))
|
|
{
|
|
if (string.IsNullOrWhiteSpace(attachment.RemoteUrl))
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var remote = await maxBridge.DownloadMediaAsync(attachment.RemoteUrl, cancellationToken);
|
|
if (remote is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
await using (remote.Stream)
|
|
{
|
|
var stored = await storage.SaveRemoteAsync(
|
|
attachment.OriginalFileName,
|
|
remote.ContentType,
|
|
remote.Stream,
|
|
remote.ContentLength ?? attachment.FileSizeBytes,
|
|
attachment.Kind,
|
|
cancellationToken);
|
|
|
|
attachment.OriginalFileName = stored.OriginalFileName;
|
|
attachment.StorageFileName = stored.StorageFileName;
|
|
attachment.ContentType = stored.ContentType;
|
|
attachment.FileSizeBytes = stored.FileSizeBytes;
|
|
attachment.Sha256 = stored.Sha256;
|
|
attachment.Kind = stored.Kind;
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
|
|
path = storage.GetPath(attachment.StorageFileName);
|
|
}
|
|
}
|
|
|
|
return PhysicalFile(path, attachment.ContentType, attachment.OriginalFileName, enableRangeProcessing: true);
|
|
}
|
|
|
|
private async Task<ActionResult?> TryApplyMaxActionAsync(
|
|
Message message,
|
|
Func<string, string, Task<MaxActionResult>> action,
|
|
string actionName,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var externalChatId = message.Chat?.ExternalId;
|
|
var externalMessageId = message.ExternalId;
|
|
if (string.IsNullOrWhiteSpace(externalChatId) || string.IsNullOrWhiteSpace(externalMessageId))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var result = await action(externalChatId, externalMessageId);
|
|
if (result.Success)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return Problem(
|
|
result.Error ?? $"MAX {actionName} action failed.",
|
|
statusCode: StatusCodes.Status409Conflict);
|
|
}
|
|
|
|
private async Task<ActionResult?> TryApplyMaxChatActionAsync(
|
|
Chat chat,
|
|
Func<string, string?, Task<MaxActionResult>> action,
|
|
string actionName,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
if (string.IsNullOrWhiteSpace(chat.ExternalId))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var chatUrl = chat.WebUrl;
|
|
if (string.IsNullOrWhiteSpace(chatUrl))
|
|
{
|
|
chatUrl = await ResolveAndPersistChatUrlAsync(chat, cancellationToken);
|
|
}
|
|
|
|
var result = await action(chat.ExternalId, chatUrl);
|
|
if (!result.Success && IsChatOpenFailure(result.Error))
|
|
{
|
|
var previousUrl = chatUrl;
|
|
chatUrl = await ResolveAndPersistChatUrlAsync(chat, cancellationToken);
|
|
if (!string.IsNullOrWhiteSpace(chatUrl) &&
|
|
!string.Equals(previousUrl, chatUrl, StringComparison.Ordinal))
|
|
{
|
|
result = await action(chat.ExternalId, chatUrl);
|
|
}
|
|
}
|
|
|
|
if (result.Success)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return Problem(
|
|
result.Error ?? $"MAX {actionName} action failed for {chat.Title}.",
|
|
statusCode: StatusCodes.Status409Conflict);
|
|
}
|
|
|
|
private async Task<string?> ResolveAndPersistChatUrlAsync(Chat chat, CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(chat.ExternalId))
|
|
{
|
|
return chat.WebUrl;
|
|
}
|
|
|
|
var result = await maxBridge.ResolveChatUrlAsync(chat.ExternalId, cancellationToken);
|
|
if (!result.Success || string.IsNullOrWhiteSpace(result.ChatUrl))
|
|
{
|
|
return chat.WebUrl;
|
|
}
|
|
|
|
var nextWebUrl = result.ChatUrl.Trim();
|
|
if (string.Equals(chat.WebUrl, nextWebUrl, StringComparison.Ordinal))
|
|
{
|
|
return chat.WebUrl;
|
|
}
|
|
|
|
chat.WebUrl = nextWebUrl;
|
|
chat.UpdatedAt = DateTimeOffset.UtcNow;
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
|
return chat.WebUrl;
|
|
}
|
|
|
|
private static bool IsChatOpenFailure(string? error)
|
|
{
|
|
return error?.Contains("not found or did not open", StringComparison.OrdinalIgnoreCase) == true ||
|
|
error?.Contains("menu was not opened", StringComparison.OrdinalIgnoreCase) == true;
|
|
}
|
|
|
|
private static void QueuePendingMaxAction(Chat chat, ChatPendingMaxAction action, DateTimeOffset requestedAt)
|
|
{
|
|
chat.PendingMaxAction = action;
|
|
chat.PendingMaxActionRequestedAt = requestedAt;
|
|
chat.PendingMaxActionLastAttemptAt = null;
|
|
chat.PendingMaxActionAttempts = 0;
|
|
chat.PendingMaxActionError = null;
|
|
}
|
|
|
|
private async Task<List<Chat>> LoadChatsWithAttachmentsAsync(IReadOnlyCollection<Guid> chatIds, CancellationToken cancellationToken)
|
|
{
|
|
return await db.Chats
|
|
.Include(x => x.Messages)
|
|
.ThenInclude(x => x.Attachments)
|
|
.Where(x => chatIds.Contains(x.Id))
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
private IEnumerable<string> AttachmentFiles(IEnumerable<Chat> chats)
|
|
{
|
|
return chats
|
|
.SelectMany(x => x.Messages)
|
|
.SelectMany(x => x.Attachments)
|
|
.Select(x => storage.GetPath(x.StorageFileName))
|
|
.Where(System.IO.File.Exists);
|
|
}
|
|
|
|
private static void DeleteFiles(IEnumerable<string> files)
|
|
{
|
|
foreach (var file in files)
|
|
{
|
|
System.IO.File.Delete(file);
|
|
}
|
|
}
|
|
|
|
private static string? NormalizeReaction(string? value)
|
|
{
|
|
var emoji = value?.Trim();
|
|
return string.IsNullOrWhiteSpace(emoji) || emoji.Length > 16 ? null : emoji;
|
|
}
|
|
|
|
private async Task<Message?> LoadMessageForProjectionAsync(Guid chatId, Guid messageId, CancellationToken cancellationToken)
|
|
{
|
|
return await db.Messages
|
|
.AsNoTracking()
|
|
.Include(x => x.Attachments)
|
|
.Include(x => x.Reactions)
|
|
.FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken);
|
|
}
|
|
|
|
private async Task RecalculateChatPreviewAsync(Chat chat, CancellationToken cancellationToken)
|
|
{
|
|
var lastMessage = (await db.Messages
|
|
.Include(x => x.Attachments)
|
|
.Where(x => x.ChatId == chat.Id && x.DeletedAt == null)
|
|
.ToArrayAsync(cancellationToken))
|
|
.OrderByDescending(x => x.SentAt)
|
|
.FirstOrDefault();
|
|
|
|
chat.LastMessageAt = lastMessage?.SentAt;
|
|
chat.LastMessagePreview = lastMessage is null ? null : ForwardPreview(lastMessage);
|
|
chat.UpdatedAt = DateTimeOffset.UtcNow;
|
|
}
|
|
|
|
private async Task<StoredAttachment> CopyForwardAttachmentAsync(MessageAttachment attachment, CancellationToken cancellationToken)
|
|
{
|
|
var path = storage.GetPath(attachment.StorageFileName);
|
|
if (System.IO.File.Exists(path))
|
|
{
|
|
await using var stream = System.IO.File.OpenRead(path);
|
|
return await storage.SaveRemoteAsync(
|
|
attachment.OriginalFileName,
|
|
attachment.ContentType,
|
|
stream,
|
|
attachment.FileSizeBytes,
|
|
attachment.Kind,
|
|
cancellationToken);
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(attachment.RemoteUrl))
|
|
{
|
|
var remote = await maxBridge.DownloadMediaAsync(attachment.RemoteUrl, cancellationToken);
|
|
if (remote is not null)
|
|
{
|
|
await using (remote.Stream)
|
|
{
|
|
return await storage.SaveRemoteAsync(
|
|
attachment.OriginalFileName,
|
|
remote.ContentType,
|
|
remote.Stream,
|
|
remote.ContentLength ?? attachment.FileSizeBytes,
|
|
attachment.Kind,
|
|
cancellationToken);
|
|
}
|
|
}
|
|
}
|
|
|
|
throw new InvalidOperationException($"Cannot forward attachment {attachment.Id}: content is unavailable.");
|
|
}
|
|
|
|
private static string? ResolveForwardedFrom(Message source)
|
|
{
|
|
return source.SenderName?.Trim().Length > 0
|
|
? source.SenderName.Trim()
|
|
: source.Chat?.Title;
|
|
}
|
|
|
|
private static string ForwardPreview(Message message)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(message.Text))
|
|
{
|
|
return message.Text;
|
|
}
|
|
|
|
var first = message.Attachments.OrderBy(x => x.SortOrder).FirstOrDefault();
|
|
return first?.Kind switch
|
|
{
|
|
AttachmentKind.Image => "\u041c\u0435\u0434\u0438\u0430",
|
|
AttachmentKind.Gif => "\u041c\u0435\u0434\u0438\u0430",
|
|
AttachmentKind.Video => "\u041c\u0435\u0434\u0438\u0430",
|
|
AttachmentKind.VoiceNote => "\u0413\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435",
|
|
AttachmentKind.Sticker => "\u041c\u0435\u0434\u0438\u0430",
|
|
AttachmentKind.Contact => "\u041a\u043e\u043d\u0442\u0430\u043a\u0442",
|
|
_ => first?.OriginalFileName ?? "\u041f\u0435\u0440\u0435\u0441\u043b\u0430\u043d\u043d\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435"
|
|
};
|
|
}
|
|
|
|
private static bool MatchesSearch(Message message, string term)
|
|
{
|
|
return Contains(message.Text, term) ||
|
|
Contains(message.SenderName, term) ||
|
|
Contains(message.ForwardedFrom, term) ||
|
|
message.Attachments.Any(x =>
|
|
Contains(x.OriginalFileName, term) ||
|
|
Contains(x.ContentType, term)) ||
|
|
message.Reactions.Any(x => Contains(x.Emoji, term));
|
|
}
|
|
|
|
private static bool Contains(string? value, string term)
|
|
{
|
|
return value?.Contains(term, StringComparison.OrdinalIgnoreCase) == true;
|
|
}
|
|
}
|