408 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			408 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
using System.ComponentModel.DataAnnotations;
 | 
						|
using System.Text.RegularExpressions;
 | 
						|
using DysonNetwork.Shared.Auth;
 | 
						|
using DysonNetwork.Shared.Data;
 | 
						|
using DysonNetwork.Shared.Models;
 | 
						|
using DysonNetwork.Shared.Proto;
 | 
						|
using DysonNetwork.Sphere.Autocompletion;
 | 
						|
using Microsoft.AspNetCore.Authorization;
 | 
						|
using Microsoft.AspNetCore.Mvc;
 | 
						|
using Microsoft.EntityFrameworkCore;
 | 
						|
 | 
						|
namespace DysonNetwork.Sphere.Chat;
 | 
						|
 | 
						|
[ApiController]
 | 
						|
[Route("/api/chat")]
 | 
						|
public partial class ChatController(
 | 
						|
    AppDatabase db,
 | 
						|
    ChatService cs,
 | 
						|
    ChatRoomService crs,
 | 
						|
    FileService.FileServiceClient files,
 | 
						|
    AccountService.AccountServiceClient accounts,
 | 
						|
    AutocompletionService aus
 | 
						|
) : ControllerBase
 | 
						|
{
 | 
						|
    public class MarkMessageReadRequest
 | 
						|
    {
 | 
						|
        public Guid ChatRoomId { get; set; }
 | 
						|
    }
 | 
						|
 | 
						|
    public class ChatRoomWsUniversalRequest
 | 
						|
    {
 | 
						|
        public Guid ChatRoomId { get; set; }
 | 
						|
    }
 | 
						|
 | 
						|
    public class ChatSummaryResponse
 | 
						|
    {
 | 
						|
        public int UnreadCount { get; set; }
 | 
						|
        public SnChatMessage? LastMessage { get; set; }
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpGet("summary")]
 | 
						|
    [Authorize]
 | 
						|
    public async Task<ActionResult<Dictionary<Guid, ChatSummaryResponse>>> GetChatSummary()
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
						|
 | 
						|
        var accountId = Guid.Parse(currentUser.Id);
 | 
						|
        var unreadMessages = await cs.CountUnreadMessageForUser(accountId);
 | 
						|
        var lastMessages = await cs.ListLastMessageForUser(accountId);
 | 
						|
 | 
						|
        var result = unreadMessages.Keys
 | 
						|
            .Union(lastMessages.Keys)
 | 
						|
            .ToDictionary(
 | 
						|
                roomId => roomId,
 | 
						|
                roomId => new ChatSummaryResponse
 | 
						|
                {
 | 
						|
                    UnreadCount = unreadMessages.GetValueOrDefault(roomId),
 | 
						|
                    LastMessage = lastMessages.GetValueOrDefault(roomId)
 | 
						|
                }
 | 
						|
            );
 | 
						|
 | 
						|
        return Ok(result);
 | 
						|
    }
 | 
						|
 | 
						|
    public class SendMessageRequest
 | 
						|
    {
 | 
						|
        [MaxLength(4096)] public string? Content { get; set; }
 | 
						|
        [MaxLength(36)] public string? Nonce { get; set; }
 | 
						|
        public List<string>? AttachmentsId { get; set; }
 | 
						|
        public Dictionary<string, object>? Meta { get; set; }
 | 
						|
        public Guid? RepliedMessageId { get; set; }
 | 
						|
        public Guid? ForwardedMessageId { get; set; }
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpGet("{roomId:guid}/messages")]
 | 
						|
    public async Task<ActionResult<List<SnChatMessage>>> ListMessages(Guid roomId, [FromQuery] int offset,
 | 
						|
        [FromQuery] int take = 20)
 | 
						|
    {
 | 
						|
        var currentUser = HttpContext.Items["CurrentUser"] as Account;
 | 
						|
 | 
						|
        var room = await db.ChatRooms.FirstOrDefaultAsync(r => r.Id == roomId);
 | 
						|
        if (room is null) return NotFound();
 | 
						|
 | 
						|
        if (!room.IsPublic)
 | 
						|
        {
 | 
						|
            if (currentUser is null) return Unauthorized();
 | 
						|
 | 
						|
            var accountId = Guid.Parse(currentUser.Id);
 | 
						|
            var member = await db.ChatMembers
 | 
						|
                .Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null &&
 | 
						|
                            m.LeaveAt == null)
 | 
						|
                .FirstOrDefaultAsync();
 | 
						|
            if (member == null || member.Role < ChatMemberRole.Member)
 | 
						|
                return StatusCode(403, "You are not a member of this chat room.");
 | 
						|
        }
 | 
						|
 | 
						|
        var totalCount = await db.ChatMessages
 | 
						|
            .Where(m => m.ChatRoomId == roomId)
 | 
						|
            .CountAsync();
 | 
						|
        var messages = await db.ChatMessages
 | 
						|
            .Where(m => m.ChatRoomId == roomId)
 | 
						|
            .OrderByDescending(m => m.CreatedAt)
 | 
						|
            .Include(m => m.Sender)
 | 
						|
            .Skip(offset)
 | 
						|
            .Take(take)
 | 
						|
            .ToListAsync();
 | 
						|
 | 
						|
        var members = messages.Select(m => m.Sender).DistinctBy(x => x.Id).ToList();
 | 
						|
        members = await crs.LoadMemberAccounts(members);
 | 
						|
 | 
						|
        foreach (var message in messages)
 | 
						|
            message.Sender = members.First(x => x.Id == message.SenderId);
 | 
						|
 | 
						|
        Response.Headers["X-Total"] = totalCount.ToString();
 | 
						|
 | 
						|
        return Ok(messages);
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpGet("{roomId:guid}/messages/{messageId:guid}")]
 | 
						|
    public async Task<ActionResult<SnChatMessage>> GetMessage(Guid roomId, Guid messageId)
 | 
						|
    {
 | 
						|
        var currentUser = HttpContext.Items["CurrentUser"] as Account;
 | 
						|
 | 
						|
        var room = await db.ChatRooms.FirstOrDefaultAsync(r => r.Id == roomId);
 | 
						|
        if (room is null) return NotFound();
 | 
						|
 | 
						|
        if (!room.IsPublic)
 | 
						|
        {
 | 
						|
            if (currentUser is null) return Unauthorized();
 | 
						|
 | 
						|
            var accountId = Guid.Parse(currentUser.Id);
 | 
						|
            var member = await db.ChatMembers
 | 
						|
                .Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null &&
 | 
						|
                            m.LeaveAt == null)
 | 
						|
                .FirstOrDefaultAsync();
 | 
						|
            if (member == null || member.Role < ChatMemberRole.Member)
 | 
						|
                return StatusCode(403, "You are not a member of this chat room.");
 | 
						|
        }
 | 
						|
 | 
						|
        var message = await db.ChatMessages
 | 
						|
            .Where(m => m.Id == messageId && m.ChatRoomId == roomId)
 | 
						|
            .Include(m => m.Sender)
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
 | 
						|
        if (message is null) return NotFound();
 | 
						|
 | 
						|
        message.Sender = await crs.LoadMemberAccount(message.Sender);
 | 
						|
 | 
						|
        return Ok(message);
 | 
						|
    }
 | 
						|
 | 
						|
 | 
						|
    [GeneratedRegex(@"@(?:u/)?([A-Za-z0-9_-]+)")]
 | 
						|
    private static partial Regex MentionRegex();
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Extracts mentioned users from message content, replies, and forwards
 | 
						|
    /// </summary>
 | 
						|
    private async Task<List<Guid>> ExtractMentionedUsersAsync(string? content, Guid? repliedMessageId,
 | 
						|
        Guid? forwardedMessageId, Guid roomId, Guid? excludeSenderId = null)
 | 
						|
    {
 | 
						|
        var mentionedUsers = new List<Guid>();
 | 
						|
 | 
						|
        // Add sender of a replied message
 | 
						|
        if (repliedMessageId.HasValue)
 | 
						|
        {
 | 
						|
            var replyingTo = await db.ChatMessages
 | 
						|
                .Where(m => m.Id == repliedMessageId.Value && m.ChatRoomId == roomId)
 | 
						|
                .Include(m => m.Sender)
 | 
						|
                .Select(m => m.Sender)
 | 
						|
                .FirstOrDefaultAsync();
 | 
						|
            if (replyingTo != null)
 | 
						|
                mentionedUsers.Add(replyingTo.AccountId);
 | 
						|
        }
 | 
						|
 | 
						|
        // Add sender of a forwarded message
 | 
						|
        if (forwardedMessageId.HasValue)
 | 
						|
        {
 | 
						|
            var forwardedMessage = await db.ChatMessages
 | 
						|
                .Where(m => m.Id == forwardedMessageId.Value)
 | 
						|
                .Select(m => new { m.SenderId })
 | 
						|
                .FirstOrDefaultAsync();
 | 
						|
            if (forwardedMessage != null)
 | 
						|
            {
 | 
						|
                mentionedUsers.Add(forwardedMessage.SenderId);
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        // Extract mentions from content using regex
 | 
						|
        if (!string.IsNullOrWhiteSpace(content))
 | 
						|
        {
 | 
						|
            var mentionedNames = MentionRegex()
 | 
						|
                .Matches(content)
 | 
						|
                .Select(m => m.Groups[1].Value)
 | 
						|
                .Distinct()
 | 
						|
                .ToList();
 | 
						|
 | 
						|
            if (mentionedNames.Count > 0)
 | 
						|
            {
 | 
						|
                var queryRequest = new LookupAccountBatchRequest();
 | 
						|
                queryRequest.Names.AddRange(mentionedNames);
 | 
						|
                var queryResponse = (await accounts.LookupAccountBatchAsync(queryRequest)).Accounts;
 | 
						|
                var mentionedIds = queryResponse.Select(a => Guid.Parse(a.Id)).ToList();
 | 
						|
 | 
						|
                if (mentionedIds.Count > 0)
 | 
						|
                {
 | 
						|
                    var mentionedMembers = await db.ChatMembers
 | 
						|
                        .Where(m => m.ChatRoomId == roomId && mentionedIds.Contains(m.AccountId))
 | 
						|
                        .Where(m => m.JoinedAt != null && m.LeaveAt == null)
 | 
						|
                        .Where(m => excludeSenderId == null || m.AccountId != excludeSenderId.Value)
 | 
						|
                        .Select(m => m.AccountId)
 | 
						|
                        .ToListAsync();
 | 
						|
                    mentionedUsers.AddRange(mentionedMembers);
 | 
						|
                }
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        return mentionedUsers.Distinct().ToList();
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpPost("{roomId:guid}/messages")]
 | 
						|
    [Authorize]
 | 
						|
    [RequiredPermission("global", "chat.messages.create")]
 | 
						|
    public async Task<ActionResult> SendMessage([FromBody] SendMessageRequest request, Guid roomId)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
						|
 | 
						|
        request.Content = TextSanitizer.Sanitize(request.Content);
 | 
						|
        if (string.IsNullOrWhiteSpace(request.Content) &&
 | 
						|
            (request.AttachmentsId == null || request.AttachmentsId.Count == 0))
 | 
						|
            return BadRequest("You cannot send an empty message.");
 | 
						|
 | 
						|
        var member = await crs.GetRoomMember(Guid.Parse(currentUser.Id), roomId);
 | 
						|
        if (member == null || member.Role < ChatMemberRole.Member)
 | 
						|
            return StatusCode(403, "You need to be a normal member to send messages here.");
 | 
						|
 | 
						|
        var message = new SnChatMessage
 | 
						|
        {
 | 
						|
            Type = "text",
 | 
						|
            SenderId = member.Id,
 | 
						|
            ChatRoomId = roomId,
 | 
						|
            Nonce = request.Nonce ?? Guid.NewGuid().ToString(),
 | 
						|
            Meta = request.Meta ?? new Dictionary<string, object>(),
 | 
						|
        };
 | 
						|
        if (request.Content is not null)
 | 
						|
            message.Content = request.Content;
 | 
						|
        if (request.AttachmentsId is not null)
 | 
						|
        {
 | 
						|
            var queryRequest = new GetFileBatchRequest();
 | 
						|
            queryRequest.Ids.AddRange(request.AttachmentsId);
 | 
						|
            var queryResponse = await files.GetFileBatchAsync(queryRequest);
 | 
						|
            message.Attachments = queryResponse.Files
 | 
						|
                .OrderBy(f => request.AttachmentsId.IndexOf(f.Id))
 | 
						|
                .Select(SnCloudFileReferenceObject.FromProtoValue)
 | 
						|
                .ToList();
 | 
						|
        }
 | 
						|
 | 
						|
        // Validate reply and forward message IDs exist
 | 
						|
        if (request.RepliedMessageId.HasValue)
 | 
						|
        {
 | 
						|
            var repliedMessage = await db.ChatMessages
 | 
						|
                .FirstOrDefaultAsync(m => m.Id == request.RepliedMessageId.Value && m.ChatRoomId == roomId);
 | 
						|
            if (repliedMessage == null)
 | 
						|
                return BadRequest("The message you're replying to does not exist.");
 | 
						|
 | 
						|
            message.RepliedMessageId = repliedMessage.Id;
 | 
						|
        }
 | 
						|
 | 
						|
        if (request.ForwardedMessageId.HasValue)
 | 
						|
        {
 | 
						|
            var forwardedMessage = await db.ChatMessages
 | 
						|
                .FirstOrDefaultAsync(m => m.Id == request.ForwardedMessageId.Value);
 | 
						|
            if (forwardedMessage == null)
 | 
						|
                return BadRequest("The message you're forwarding does not exist.");
 | 
						|
 | 
						|
            message.ForwardedMessageId = forwardedMessage.Id;
 | 
						|
        }
 | 
						|
 | 
						|
        // Extract mentioned users
 | 
						|
        message.MembersMentioned = await ExtractMentionedUsersAsync(request.Content, request.RepliedMessageId,
 | 
						|
            request.ForwardedMessageId, roomId);
 | 
						|
 | 
						|
        var result = await cs.SendMessageAsync(message, member, member.ChatRoom);
 | 
						|
 | 
						|
        return Ok(result);
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpPatch("{roomId:guid}/messages/{messageId:guid}")]
 | 
						|
    [Authorize]
 | 
						|
    public async Task<ActionResult> UpdateMessage([FromBody] SendMessageRequest request, Guid roomId, Guid messageId)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
						|
 | 
						|
        request.Content = TextSanitizer.Sanitize(request.Content);
 | 
						|
 | 
						|
        var message = await db.ChatMessages
 | 
						|
            .Include(m => m.Sender)
 | 
						|
            .Include(message => message.ChatRoom)
 | 
						|
            .FirstOrDefaultAsync(m => m.Id == messageId && m.ChatRoomId == roomId);
 | 
						|
 | 
						|
        if (message == null) return NotFound();
 | 
						|
 | 
						|
        var accountId = Guid.Parse(currentUser.Id);
 | 
						|
        if (message.Sender.AccountId != accountId)
 | 
						|
            return StatusCode(403, "You can only edit your own messages.");
 | 
						|
 | 
						|
        if (string.IsNullOrWhiteSpace(request.Content) &&
 | 
						|
            (request.AttachmentsId == null || request.AttachmentsId.Count == 0))
 | 
						|
            return BadRequest("You cannot send an empty message.");
 | 
						|
 | 
						|
        // Validate reply and forward message IDs exist
 | 
						|
        if (request.RepliedMessageId.HasValue)
 | 
						|
        {
 | 
						|
            var repliedMessage = await db.ChatMessages
 | 
						|
                .FirstOrDefaultAsync(m => m.Id == request.RepliedMessageId.Value && m.ChatRoomId == roomId);
 | 
						|
            if (repliedMessage == null)
 | 
						|
                return BadRequest("The message you're replying to does not exist.");
 | 
						|
        }
 | 
						|
 | 
						|
        if (request.ForwardedMessageId.HasValue)
 | 
						|
        {
 | 
						|
            var forwardedMessage = await db.ChatMessages
 | 
						|
                .FirstOrDefaultAsync(m => m.Id == request.ForwardedMessageId.Value);
 | 
						|
            if (forwardedMessage == null)
 | 
						|
                return BadRequest("The message you're forwarding does not exist.");
 | 
						|
        }
 | 
						|
 | 
						|
        // Update mentions based on new content and references
 | 
						|
        var updatedMentions = await ExtractMentionedUsersAsync(request.Content, request.RepliedMessageId,
 | 
						|
            request.ForwardedMessageId, roomId, accountId);
 | 
						|
        message.MembersMentioned = updatedMentions;
 | 
						|
 | 
						|
        // Call service method to update the message
 | 
						|
        await cs.UpdateMessageAsync(
 | 
						|
            message,
 | 
						|
            request.Meta,
 | 
						|
            request.Content,
 | 
						|
            request.RepliedMessageId,
 | 
						|
            request.ForwardedMessageId,
 | 
						|
            request.AttachmentsId
 | 
						|
        );
 | 
						|
 | 
						|
        return Ok(message);
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpDelete("{roomId:guid}/messages/{messageId:guid}")]
 | 
						|
    [Authorize]
 | 
						|
    public async Task<ActionResult> DeleteMessage(Guid roomId, Guid messageId)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
						|
 | 
						|
        var message = await db.ChatMessages
 | 
						|
            .Include(m => m.Sender)
 | 
						|
            .Include(m => m.ChatRoom)
 | 
						|
            .FirstOrDefaultAsync(m => m.Id == messageId && m.ChatRoomId == roomId);
 | 
						|
 | 
						|
        if (message == null) return NotFound();
 | 
						|
 | 
						|
        var accountId = Guid.Parse(currentUser.Id);
 | 
						|
        if (message.Sender.AccountId != accountId)
 | 
						|
            return StatusCode(403, "You can only delete your own messages.");
 | 
						|
 | 
						|
        // Call service method to delete the message
 | 
						|
        await cs.DeleteMessageAsync(message);
 | 
						|
 | 
						|
        return Ok();
 | 
						|
    }
 | 
						|
 | 
						|
    public class SyncRequest
 | 
						|
    {
 | 
						|
        [Required] public long LastSyncTimestamp { get; set; }
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpPost("{roomId:guid}/sync")]
 | 
						|
    public async Task<ActionResult<SyncResponse>> GetSyncData([FromBody] SyncRequest request, Guid roomId)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
						|
            return Unauthorized();
 | 
						|
 | 
						|
        var accountId = Guid.Parse(currentUser.Id);
 | 
						|
        var isMember = await db.ChatMembers
 | 
						|
            .AnyAsync(m =>
 | 
						|
                m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null);
 | 
						|
        if (!isMember)
 | 
						|
            return StatusCode(403, "You are not a member of this chat room.");
 | 
						|
 | 
						|
        var response = await cs.GetSyncDataAsync(roomId, request.LastSyncTimestamp);
 | 
						|
        return Ok(response);
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpPost("{roomId:guid}/autocomplete")]
 | 
						|
    public async Task<ActionResult<List<DysonNetwork.Shared.Models.Autocompletion>>> ChatAutoComplete(
 | 
						|
        [FromBody] AutocompletionRequest request, Guid roomId)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
						|
            return Unauthorized();
 | 
						|
 | 
						|
        var accountId = Guid.Parse(currentUser.Id);
 | 
						|
        var isMember = await db.ChatMembers
 | 
						|
            .AnyAsync(m =>
 | 
						|
                m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null);
 | 
						|
        if (!isMember)
 | 
						|
            return StatusCode(403, "You are not a member of this chat room.");
 | 
						|
 | 
						|
        var result = await aus.GetAutocompletion(request.Content, chatId: roomId, limit: 10);
 | 
						|
        return Ok(result);
 | 
						|
    }
 | 
						|
} |