Add chat bulk actions and avatar cache

This commit is contained in:
sevenhill
2026-07-05 17:49:08 +03:00
parent 81dc918a50
commit 4db643b028
16 changed files with 982 additions and 35 deletions
@@ -49,5 +49,6 @@ public sealed record ForwardMessageRequest(Guid TargetChatId);
public sealed record SetReactionRequest(string Emoji);
public sealed record MessageDeletedDto(Guid ChatId, Guid MessageId);
public sealed record CreateDirectChatRequest(string ExternalChatId, string Title, string? AvatarUrl = null);
public sealed record ChatBulkActionRequest(IReadOnlyList<Guid> ChatIds);
public sealed record ChatPresenceDto(bool IsTyping, string? StatusText, DateTimeOffset UpdatedAt);
@@ -121,6 +121,86 @@ public sealed class ChatsController(
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();
}
foreach (var chat in chats)
{
var maxFailure = await TryApplyMaxChatActionAsync(
chat,
(externalChatId, chatUrl) => maxBridge.ClearChatHistoryAsync(externalChatId, chatUrl, cancellationToken),
"clear history",
cancellationToken);
if (maxFailure is not null)
{
return maxFailure;
}
}
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.UpdatedAt = DateTimeOffset.UtcNow;
}
await db.SaveChangesAsync(cancellationToken);
DeleteFiles(files);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
return NoContent();
}
[HttpPost("delete")]
public async Task<IActionResult> DeleteChats(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();
}
foreach (var chat in chats)
{
var maxFailure = await TryApplyMaxChatActionAsync(
chat,
(externalChatId, chatUrl) => maxBridge.DeleteChatAsync(externalChatId, chatUrl, cancellationToken),
"delete chat",
cancellationToken);
if (maxFailure is not null)
{
return maxFailure;
}
}
var files = AttachmentFiles(chats).ToArray();
db.Chats.RemoveRange(chats);
await db.SaveChangesAsync(cancellationToken);
DeleteFiles(files);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
return NoContent();
}
[HttpGet("{chatId:guid}/search")]
public async Task<ActionResult<IReadOnlyList<MessageDto>>> SearchMessages(
Guid chatId,
@@ -656,6 +736,56 @@ public sealed class ChatsController(
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 result = await action(chat.ExternalId, chat.WebUrl);
if (result.Success)
{
return null;
}
return Problem(
result.Error ?? $"MAX {actionName} action failed for {chat.Title}.",
statusCode: StatusCodes.Status409Conflict);
}
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();
@@ -9,6 +9,8 @@ public interface IMaxBridgeClient
Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken);
Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken);
Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string? chatUrl, string path, string caption, CancellationToken cancellationToken);
Task<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken);
@@ -56,6 +56,16 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
return Task.FromResult<MaxChatPresence?>(new MaxChatPresence(false, null, DateTimeOffset.UtcNow));
}
public Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxSendResult(true, $"mock-out-{Guid.NewGuid():N}", null, MockChatUrl(externalChatId)));
@@ -53,6 +53,26 @@ public sealed class WorkerMaxBridgeClient(
return await SendAsync<MaxChatPresence>(HttpMethod.Post, "/chat/presence", new { externalChatId, chatUrl }, cancellationToken);
}
public async Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return await SendAsync<MaxActionResult>(
HttpMethod.Post,
"/chat/clear-history",
new { externalChatId, chatUrl },
cancellationToken)
?? new MaxActionResult(false, "Worker returned an empty clear history result.");
}
public async Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return await SendAsync<MaxActionResult>(
HttpMethod.Post,
"/chat/delete",
new { externalChatId, chatUrl },
cancellationToken)
?? new MaxActionResult(false, "Worker returned an empty delete chat result.");
}
public async Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
{
return await SendAsync<MaxSendResult>(HttpMethod.Post, "/send/text", new { externalChatId, chatUrl, text }, cancellationToken)