From dceaceb9414e8ff7473bec107c2a87da7d7d7ea7 Mon Sep 17 00:00:00 2001 From: azyges <229218900+azyges@users.noreply.github.com> Date: Wed, 29 Oct 2025 07:50:41 +0900 Subject: [PATCH] chat --- LightlessAPI | 2 +- .../Hubs/LightlessHub.Chat.cs | 578 ++++++++ .../Hubs/LightlessHub.ClientStubs.cs | 2 + .../LightlessSyncServer/Hubs/LightlessHub.cs | 9 +- .../Services/ChatChannelService.cs | 514 +++++++ .../LightlessSyncServer/Startup.cs | 1 + .../Discord/DiscordBot.cs | 390 ++++- .../LightlessSyncShared/Data/MareDbContext.cs | 7 + .../20251028215549_ChatReports.Designer.cs | 1318 +++++++++++++++++ .../Migrations/20251028215549_ChatReports.cs | 90 ++ .../LightlessDbContextModelSnapshot.cs | 129 ++ .../Models/ReportedChatMessage.cs | 69 + .../LightlessSyncShared/Models/User.cs | 2 + .../Configuration/ServicesConfiguration.cs | 2 + 14 files changed, 3108 insertions(+), 5 deletions(-) create mode 100644 LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs create mode 100644 LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs create mode 100644 LightlessSyncServer/LightlessSyncShared/Migrations/20251028215549_ChatReports.Designer.cs create mode 100644 LightlessSyncServer/LightlessSyncShared/Migrations/20251028215549_ChatReports.cs create mode 100644 LightlessSyncServer/LightlessSyncShared/Models/ReportedChatMessage.cs diff --git a/LightlessAPI b/LightlessAPI index bb92cd4..67cb24a 160000 --- a/LightlessAPI +++ b/LightlessAPI @@ -1 +1 @@ -Subproject commit bb92cd477d76f24fd28200ade00076bc77fe299d +Subproject commit 67cb24a069bd769a38f3608e32db3f86e906823c diff --git a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs new file mode 100644 index 0000000..0b1f198 --- /dev/null +++ b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs @@ -0,0 +1,578 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using LightlessSync.API.Data.Enum; +using LightlessSync.API.Dto.Chat; +using LightlessSync.API.Dto.User; +using LightlessSyncServer.Services; +using LightlessSyncServer.Utils; +using LightlessSyncShared.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; + +namespace LightlessSyncServer.Hubs; + +public partial class LightlessHub +{ + private const int MaxChatMessageLength = 400; + private static readonly JsonSerializerOptions ChatReportSnapshotSerializerOptions = new(JsonSerializerDefaults.General) + { + WriteIndented = false + }; + + [Authorize(Policy = "Identified")] + public Task> GetZoneChatChannels() + { + return Task.FromResult(_chatChannelService.GetZoneChannelInfos()); + } + + [Authorize(Policy = "Identified")] + public async Task> GetGroupChatChannels() + { + var userUid = UserUID; + + var groupInfos = await DbContext.Groups + .AsNoTracking() + .Where(g => g.OwnerUID == userUid + || DbContext.GroupPairs.Any(p => p.GroupGID == g.GID && p.GroupUserUID == userUid)) + .ToListAsync() + .ConfigureAwait(false); + + return groupInfos + .Select(g => + { + var displayName = string.IsNullOrWhiteSpace(g.Alias) ? g.GID : g.Alias!; + var descriptor = new ChatChannelDescriptor + { + Type = ChatChannelType.Group, + WorldId = 0, + ZoneId = 0, + CustomKey = g.GID + }; + + return new GroupChatChannelInfoDto( + descriptor, + displayName, + g.GID, + g.OwnerUID == userUid); + }) + .OrderBy(info => info.DisplayName, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + [Authorize(Policy = "Identified")] + public async Task UpdateChatPresence(ChatPresenceUpdateDto presence) + { + var channel = presence.Channel.WithNormalizedCustomKey(); + + var userRecord = await DbContext.Users + .AsNoTracking() + .SingleAsync(u => u.UID == UserUID, cancellationToken: RequestAbortedToken) + .ConfigureAwait(false); + + if (userRecord.ChatBanned) + { + _chatChannelService.RemovePresence(UserUID); + await NotifyChatBanAsync(UserUID).ConfigureAwait(false); + return; + } + + if (!presence.IsActive) + { + _chatChannelService.RemovePresence(UserUID, channel); + return; + } + + switch (channel.Type) + { + case ChatChannelType.Zone: + if (!_chatChannelService.TryResolveZone(channel.CustomKey, out var definition)) + { + throw new HubException("Unsupported chat channel."); + } + + string? hashedCid = null; + var isLightfinder = false; + if (IsValidHashedCid(UserCharaIdent)) + { + var (entry, expiry) = await TryGetBroadcastEntryAsync(UserCharaIdent).ConfigureAwait(false); + isLightfinder = HasActiveBroadcast(entry, expiry); + if (isLightfinder) + { + hashedCid = UserCharaIdent; + } + } + + _chatChannelService.UpdateZonePresence( + UserUID, + definition, + channel.WorldId, + hashedCid, + isLightfinder, + isActive: true); + break; + + case ChatChannelType.Group: + var groupKey = channel.CustomKey ?? string.Empty; + if (string.IsNullOrEmpty(groupKey)) + { + throw new HubException("Unsupported chat channel."); + } + + var userData = userRecord.ToUserData(); + + var group = await DbContext.Groups + .AsNoTracking() + .SingleOrDefaultAsync(g => g.GID == groupKey, cancellationToken: RequestAbortedToken) + .ConfigureAwait(false); + + if (group is null) + { + throw new HubException("Unsupported chat channel."); + } + + var isMember = string.Equals(group.OwnerUID, UserUID, StringComparison.Ordinal) + || await DbContext.GroupPairs + .AsNoTracking() + .AnyAsync(gp => gp.GroupGID == groupKey && gp.GroupUserUID == UserUID, cancellationToken: RequestAbortedToken) + .ConfigureAwait(false); + + if (!isMember) + { + throw new HubException("Join the syncshell before using chat."); + } + + var displayName = string.IsNullOrWhiteSpace(group.Alias) ? group.GID : group.Alias; + + _chatChannelService.UpdateGroupPresence( + UserUID, + group.GID, + displayName, + userData, + IsValidHashedCid(UserCharaIdent) ? UserCharaIdent : null, + isActive: true); + break; + + default: + throw new HubException("Unsupported chat channel."); + } + } + + [Authorize(Policy = "Identified")] + public async Task SendChatMessage(ChatSendRequestDto request) + { + if (string.IsNullOrWhiteSpace(request.Message)) + { + throw new HubException("Message cannot be empty."); + } + + var channel = request.Channel.WithNormalizedCustomKey(); + + if (await HandleIfChatBannedAsync(UserUID).ConfigureAwait(false)) + { + throw new HubException("Chat access has been revoked."); + } + + if (!_chatChannelService.TryGetPresence(UserUID, channel, out var presence)) + { + throw new HubException("Join a chat channel before sending messages."); + } + + var sanitizedMessage = request.Message.Trim().ReplaceLineEndings(" "); + if (sanitizedMessage.Length > MaxChatMessageLength) + { + sanitizedMessage = sanitizedMessage[..MaxChatMessageLength]; + } + + var recipients = _chatChannelService.GetMembers(presence.Channel); + var recipientsList = recipients.ToList(); + if (recipientsList.Count == 0) + { + return; + } + + var bannedRecipients = recipientsList.Count == 0 + ? new List() + : await DbContext.Users.AsNoTracking() + .Where(u => recipientsList.Contains(u.UID) && u.ChatBanned) + .Select(u => u.UID) + .ToListAsync(RequestAbortedToken) + .ConfigureAwait(false); + + HashSet? bannedSet = null; + if (bannedRecipients.Count > 0) + { + bannedSet = new HashSet(bannedRecipients, StringComparer.Ordinal); + foreach (var bannedUid in bannedSet) + { + _chatChannelService.RemovePresence(bannedUid); + await NotifyChatBanAsync(bannedUid).ConfigureAwait(false); + } + } + + var deliveryTargets = new Dictionary(StringComparer.Ordinal); + foreach (var uid in recipientsList) + { + if (bannedSet != null && bannedSet.Contains(uid)) + { + continue; + } + + if (_userConnections.TryGetValue(uid, out var connectionId)) + { + var includeSensitive = AllowsLightfinderDetails(presence.Channel, uid); + if (deliveryTargets.TryGetValue(connectionId, out var existing)) + { + deliveryTargets[connectionId] = (existing.Uid, existing.IncludeSensitive || includeSensitive); + } + else + { + deliveryTargets[connectionId] = (uid, includeSensitive); + } + } + else + { + _chatChannelService.RemovePresence(uid); + } + } + + if (deliveryTargets.Count == 0) + { + return; + } + + var timestamp = DateTime.UtcNow; + var messageId = _chatChannelService.RecordMessage(presence.Channel, presence.Participant, sanitizedMessage, timestamp); + var sendTasks = new List(deliveryTargets.Count); + + foreach (var (connectionId, target) in deliveryTargets) + { + var sender = BuildSenderDescriptor(presence.Channel, presence.Participant, target.IncludeSensitive); + var payload = new ChatMessageDto( + presence.Channel, + sender, + sanitizedMessage, + timestamp, + messageId); + + sendTasks.Add(Clients.Client(connectionId).Client_ChatReceive(payload)); + } + + await Task.WhenAll(sendTasks).ConfigureAwait(false); + } + + [Authorize(Policy = "Identified")] + public async Task ResolveChatParticipant(ChatParticipantResolveRequestDto request) + { + var channel = request.Channel.WithNormalizedCustomKey(); + + if (!_chatChannelService.TryGetPresence(UserUID, channel, out _)) + { + throw new HubException("Join the chat channel before resolving participants."); + } + + if (!_chatChannelService.TryResolveParticipant(channel, request.Token, out var participant)) + { + return null; + } + + var viewerAllowsDetails = await ViewerAllowsLightfinderDetailsAsync(channel).ConfigureAwait(false); + var includeSensitiveInfo = channel.Type == ChatChannelType.Group || viewerAllowsDetails; + + var sender = BuildSenderDescriptor(channel, participant, includeSensitiveInfo); + + if (!includeSensitiveInfo) + { + return new ChatParticipantResolveResultDto(channel, sender, null); + } + + UserProfileDto? profile = null; + if (channel.Type == ChatChannelType.Group) + { + profile = await LoadChatParticipantProfileAsync(participant.UserUid).ConfigureAwait(false); + } + else if (participant.IsLightfinder && !string.IsNullOrEmpty(participant.HashedCid)) + { + profile = await LoadChatParticipantProfileAsync(participant.UserUid).ConfigureAwait(false); + } + + return new ChatParticipantResolveResultDto(channel, sender, profile); + } + + [Authorize(Policy = "Identified")] + public async Task ReportChatMessage(ChatReportSubmitDto request) + { + var channel = request.Channel.WithNormalizedCustomKey(); + + if (!_chatChannelService.TryGetPresence(UserUID, channel, out _)) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Join the chat channel before reporting messages.").ConfigureAwait(false); + return; + } + + if (!_chatChannelService.TryGetMessage(request.MessageId, out var messageEntry)) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Unable to locate the reported message. It may have already expired.").ConfigureAwait(false); + return; + } + + var requestedChannelKey = ChatChannelService.ChannelKey.FromDescriptor(channel); + var messageChannelKey = ChatChannelService.ChannelKey.FromDescriptor(messageEntry.Channel.WithNormalizedCustomKey()); + if (!requestedChannelKey.Equals(messageChannelKey)) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "The reported message no longer matches this channel.").ConfigureAwait(false); + return; + } + + if (string.Equals(messageEntry.SenderUserUid, UserUID, StringComparison.Ordinal)) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You cannot report your own message.").ConfigureAwait(false); + return; + } + + var reason = request.Reason?.Trim(); + if (string.IsNullOrWhiteSpace(reason)) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "Please provide a short explanation for the report.").ConfigureAwait(false); + return; + } + + const int MaxReasonLength = 500; + if (reason.Length > MaxReasonLength) + { + reason = reason[..MaxReasonLength]; + } + + var additionalContext = string.IsNullOrWhiteSpace(request.AdditionalContext) + ? null + : request.AdditionalContext.Trim(); + + const int MaxContextLength = 1000; + if (!string.IsNullOrEmpty(additionalContext) && additionalContext.Length > MaxContextLength) + { + additionalContext = additionalContext[..MaxContextLength]; + } + + var alreadyReported = await DbContext.ReportedChatMessages + .AsNoTracking() + .AnyAsync(r => r.MessageId == request.MessageId && r.ReporterUserUid == UserUID && !r.Resolved, cancellationToken: RequestAbortedToken) + .ConfigureAwait(false); + + if (alreadyReported) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You already reported this message and it is pending review.").ConfigureAwait(false); + return; + } + + var oneHourAgo = DateTime.UtcNow - TimeSpan.FromHours(1); + var reportRateLimited = await DbContext.ReportedChatMessages + .AsNoTracking() + .AnyAsync(r => r.ReporterUserUid == UserUID && r.ReportTimeUtc >= oneHourAgo, cancellationToken: RequestAbortedToken) + .ConfigureAwait(false); + + if (reportRateLimited) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You can file at most one chat report per hour.").ConfigureAwait(false); + return; + } + + if (!string.IsNullOrEmpty(messageEntry.SenderUserUid)) + { + var targetAlreadyPending = await DbContext.ReportedChatMessages + .AsNoTracking() + .AnyAsync(r => r.ReportedUserUid == messageEntry.SenderUserUid && !r.Resolved, cancellationToken: RequestAbortedToken) + .ConfigureAwait(false); + + if (targetAlreadyPending) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, "This user already has a report pending review.").ConfigureAwait(false); + return; + } + } + + var snapshotEntries = _chatChannelService.GetRecentMessages(messageEntry.Channel, 25); + var snapshotItems = snapshotEntries + .Select(e => new ChatReportSnapshotItem( + e.MessageId, + e.SentAtUtc, + e.SenderUserUid, + e.SenderUser?.AliasOrUID, + e.SenderIsLightfinder, + e.SenderHashedCid, + e.Message)) + .ToArray(); + + var snapshotJson = JsonSerializer.Serialize(snapshotItems, ChatReportSnapshotSerializerOptions); + + var report = new ReportedChatMessage + { + ReportTimeUtc = DateTime.UtcNow, + ReporterUserUid = UserUID, + ReportedUserUid = messageEntry.SenderUserUid, + ChannelType = messageEntry.Channel.Type, + WorldId = messageEntry.Channel.WorldId, + ZoneId = messageEntry.Channel.ZoneId, + ChannelKey = messageEntry.Channel.CustomKey ?? string.Empty, + MessageId = messageEntry.MessageId, + MessageSentAtUtc = messageEntry.SentAtUtc, + MessageContent = messageEntry.Message, + SenderToken = messageEntry.SenderToken, + SenderHashedCid = messageEntry.SenderHashedCid, + SenderDisplayName = messageEntry.SenderUser?.AliasOrUID, + SenderWasLightfinder = messageEntry.SenderIsLightfinder, + SnapshotJson = snapshotJson, + Reason = reason, + AdditionalContext = additionalContext + }; + + DbContext.ReportedChatMessages.Add(report); + await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false); + + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, "Thank you. Your report has been queued for moderator review.").ConfigureAwait(false); + } + + private ChatSenderDescriptor BuildSenderDescriptor(ChatChannelDescriptor descriptor, ChatChannelService.ChatParticipantInfo participant, bool includeSensitiveInfo = false) + { + var kind = descriptor.Type == ChatChannelType.Group + ? ChatSenderKind.IdentifiedUser + : ChatSenderKind.Anonymous; + + string? displayName; + if (kind == ChatSenderKind.IdentifiedUser) + { + displayName = participant.User?.Alias ?? participant.User?.UID ?? participant.UserUid; + } + else if (includeSensitiveInfo && participant.IsLightfinder && !string.IsNullOrEmpty(participant.HashedCid)) + { + displayName = participant.HashedCid; + } + else + { + var source = participant.UserUid ?? string.Empty; + var suffix = source.Length >= 4 ? source[^4..] : source; + displayName = string.IsNullOrEmpty(suffix) ? "Anonymous" : $"Anon-{suffix}"; + } + + var hashedCid = includeSensitiveInfo && participant.IsLightfinder + ? participant.HashedCid + : null; + + var canResolveProfile = kind == ChatSenderKind.IdentifiedUser || participant.IsLightfinder; + + return new ChatSenderDescriptor( + kind, + participant.Token, + displayName, + hashedCid, + descriptor.Type == ChatChannelType.Group ? participant.User : null, + canResolveProfile); + } + + private async Task LoadChatParticipantProfileAsync(string userUid) + { + if (string.IsNullOrEmpty(userUid)) + return null; + + var targetUser = await DbContext.Users + .AsNoTracking() + .SingleOrDefaultAsync(u => u.UID == userUid, cancellationToken: RequestAbortedToken) + .ConfigureAwait(false); + + if (targetUser is null) + return null; + + var userData = targetUser.ToUserData(); + + var profileData = await DbContext.UserProfileData + .AsNoTracking() + .SingleOrDefaultAsync(p => p.UserUID == userUid, cancellationToken: RequestAbortedToken) + .ConfigureAwait(false); + + if (profileData is null) + { + return new UserProfileDto(userData, Disabled: false, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, Description: null, Tags: Array.Empty()); + } + + if (profileData.FlaggedForReport) + { + return new UserProfileDto(userData, Disabled: true, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, Description: "This profile is flagged for report and pending evaluation", Tags: Array.Empty()); + } + + if (profileData.ProfileDisabled) + { + return new UserProfileDto(userData, Disabled: true, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, Description: "This profile was permanently disabled", Tags: Array.Empty()); + } + + return profileData.ToDTO(); + } + + private async Task ViewerAllowsLightfinderDetailsAsync(ChatChannelDescriptor descriptor) + { + if (descriptor.Type == ChatChannelType.Group) + { + return true; + } + + var viewerCid = UserCharaIdent; + if (!IsValidHashedCid(viewerCid)) + { + return false; + } + + var (entry, expiry) = await TryGetBroadcastEntryAsync(viewerCid).ConfigureAwait(false); + return HasActiveBroadcast(entry, expiry); + } + + private bool AllowsLightfinderDetails(ChatChannelDescriptor descriptor, string userUid) + { + if (descriptor.Type == ChatChannelType.Group) + { + return true; + } + + if (_chatChannelService.TryGetPresence(userUid, descriptor, out var presence)) + { + return presence.Participant.IsLightfinder; + } + + return false; + } + + private async Task HandleIfChatBannedAsync(string userUid) + { + var isBanned = await DbContext.Users + .AsNoTracking() + .AnyAsync(u => u.UID == userUid && u.ChatBanned, RequestAbortedToken) + .ConfigureAwait(false); + + if (!isBanned) + return false; + + _chatChannelService.RemovePresence(userUid); + await NotifyChatBanAsync(userUid).ConfigureAwait(false); + return true; + } + + private async Task NotifyChatBanAsync(string userUid) + { + if (string.Equals(userUid, UserUID, StringComparison.Ordinal)) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your chat access has been revoked.").ConfigureAwait(false); + } + else if (_userConnections.TryGetValue(userUid, out var connectionId)) + { + await Clients.Client(connectionId).Client_ReceiveServerMessage(MessageSeverity.Error, "Your chat access has been revoked.").ConfigureAwait(false); + } + } + + private readonly record struct ChatReportSnapshotItem( + string MessageId, + DateTime SentAtUtc, + string SenderUserUid, + string? SenderAlias, + bool SenderIsLightfinder, + string? SenderHashedCid, + string Message); +} \ No newline at end of file diff --git a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.ClientStubs.cs b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.ClientStubs.cs index 7fbb954..4e5afcb 100644 --- a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.ClientStubs.cs +++ b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.ClientStubs.cs @@ -1,6 +1,7 @@ using LightlessSync.API.Data; using LightlessSync.API.Data.Enum; using LightlessSync.API.Dto; +using LightlessSync.API.Dto.Chat; using LightlessSync.API.Dto.CharaData; using LightlessSync.API.Dto.Group; using LightlessSync.API.Dto.User; @@ -38,5 +39,6 @@ namespace LightlessSyncServer.Hubs public Task Client_GposeLobbyPushCharacterData(CharaDataDownloadDto charaDownloadDto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported"); public Task Client_GposeLobbyPushPoseData(UserData userData, PoseData poseData) => throw new PlatformNotSupportedException("Calling clientside method on server not supported"); public Task Client_GposeLobbyPushWorldData(UserData userData, WorldData worldData) => throw new PlatformNotSupportedException("Calling clientside method on server not supported"); + public Task Client_ChatReceive(ChatMessageDto message) => throw new PlatformNotSupportedException("Calling clientside method on server not supported"); } } \ No newline at end of file diff --git a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.cs b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.cs index b366bf2..098eb20 100644 --- a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.cs +++ b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.cs @@ -1,6 +1,7 @@ using LightlessSync.API.Data; using LightlessSync.API.Data.Enum; using LightlessSync.API.Dto; +using LightlessSync.API.Dto.Chat; using LightlessSync.API.SignalR; using LightlessSyncServer.Services; using LightlessSyncServer.Configuration; @@ -16,6 +17,8 @@ using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using StackExchange.Redis.Extensions.Core.Abstractions; using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; namespace LightlessSyncServer.Hubs; @@ -43,6 +46,7 @@ public partial class LightlessHub : Hub, ILightlessHub private LightlessDbContext DbContext => _dbContextLazy.Value; private readonly int _maxCharaDataByUser; private readonly int _maxCharaDataByUserVanity; + private readonly ChatChannelService _chatChannelService; private CancellationToken RequestAbortedToken => _contextAccessor.HttpContext?.RequestAborted ?? Context?.ConnectionAborted ?? CancellationToken.None; @@ -50,7 +54,8 @@ public partial class LightlessHub : Hub, ILightlessHub IDbContextFactory lightlessDbContextFactory, ILogger logger, SystemInfoService systemInfoService, IConfigurationService configuration, IHttpContextAccessor contextAccessor, IRedisDatabase redisDb, OnlineSyncedPairCacheService onlineSyncedPairCacheService, LightlessCensus lightlessCensus, - GPoseLobbyDistributionService gPoseLobbyDistributionService, IBroadcastConfiguration broadcastConfiguration, PairService pairService) + GPoseLobbyDistributionService gPoseLobbyDistributionService, IBroadcastConfiguration broadcastConfiguration, PairService pairService, + ChatChannelService chatChannelService) { _lightlessMetrics = lightlessMetrics; _systemInfoService = systemInfoService; @@ -71,6 +76,7 @@ public partial class LightlessHub : Hub, ILightlessHub _dbContextLazy = new Lazy(() => lightlessDbContextFactory.CreateDbContext()); _broadcastConfiguration = broadcastConfiguration; _pairService = pairService; + _chatChannelService = chatChannelService; } protected override void Dispose(bool disposing) @@ -221,6 +227,7 @@ public partial class LightlessHub : Hub, ILightlessHub catch { } finally { + _chatChannelService.RemovePresence(UserUID); _userConnections.Remove(UserUID, out _); } } diff --git a/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs b/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs new file mode 100644 index 0000000..30a24b5 --- /dev/null +++ b/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs @@ -0,0 +1,514 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using LightlessSync.API.Data; +using LightlessSync.API.Dto.Chat; +using Microsoft.Extensions.Logging; + +namespace LightlessSyncServer.Services; + +public sealed class ChatChannelService +{ + private readonly ILogger _logger; + private readonly Dictionary _zoneDefinitions = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _membersByChannel = new(); + private readonly Dictionary> _presenceByUser = new(StringComparer.Ordinal); + private readonly Dictionary> _participantsByChannel = new(); + private readonly Dictionary> _messagesByChannel = new(); + private readonly Dictionary Node)> _messageIndex = new(StringComparer.Ordinal); + private readonly object _syncRoot = new(); + private const int MaxMessagesPerChannel = 40; + + public ChatChannelService(ILogger logger) + { + _logger = logger; + + AddZoneDefinition(new ZoneChannelDefinition( + Key: "limsa", + DisplayName: "Limsa Lominsa", + Descriptor: new ChatChannelDescriptor + { + Type = ChatChannelType.Zone, + WorldId = 0, + ZoneId = 0, + CustomKey = "limsa" + }, + TerritoryNames: new[] + { + "Limsa Lominsa Lower Decks", + "Limsa Lominsa Upper Decks" + })); + + AddZoneDefinition(new ZoneChannelDefinition( + Key: "gridania", + DisplayName: "Gridania", + Descriptor: new ChatChannelDescriptor + { + Type = ChatChannelType.Zone, + WorldId = 0, + ZoneId = 0, + CustomKey = "gridania" + }, + TerritoryNames: new[] + { + "New Gridania", + "Old Gridania" + })); + + AddZoneDefinition(new ZoneChannelDefinition( + Key: "uldah", + DisplayName: "Ul'dah", + Descriptor: new ChatChannelDescriptor + { + Type = ChatChannelType.Zone, + WorldId = 0, + ZoneId = 0, + CustomKey = "uldah" + }, + TerritoryNames: new[] + { + "Ul'dah - Steps of Nald", + "Ul'dah - Steps of Thal" + })); + } + + public IReadOnlyList GetZoneChannelInfos() => + _zoneDefinitions.Values + .Select(definition => new ZoneChatChannelInfoDto( + definition.Descriptor, + definition.DisplayName, + definition.TerritoryNames)) + .ToArray(); + + public bool TryResolveZone(string? key, out ZoneChannelDefinition definition) + { + definition = default; + if (string.IsNullOrWhiteSpace(key)) + return false; + + return _zoneDefinitions.TryGetValue(key, out definition); + } + + public ChatPresenceEntry? UpdateZonePresence( + string userUid, + ZoneChannelDefinition definition, + ushort worldId, + string? hashedCid, + bool isLightfinder, + bool isActive) + { + var descriptor = definition.Descriptor with { WorldId = worldId }; + var participant = new ChatParticipantInfo( + Token: string.Empty, + UserUid: userUid, + User: null, + HashedCid: isLightfinder ? hashedCid : null, + IsLightfinder: isLightfinder); + + return UpdatePresence( + userUid, + descriptor, + definition.DisplayName, + participant, + isActive, + replaceExistingOfSameType: true); + } + + public ChatPresenceEntry? UpdateGroupPresence( + string userUid, + string groupId, + string displayName, + UserData user, + string? hashedCid, + bool isActive) + { + var descriptor = new ChatChannelDescriptor + { + Type = ChatChannelType.Group, + WorldId = 0, + ZoneId = 0, + CustomKey = groupId + }; + + var participant = new ChatParticipantInfo( + Token: string.Empty, + UserUid: userUid, + User: user, + HashedCid: hashedCid, + IsLightfinder: !string.IsNullOrEmpty(hashedCid)); + + return UpdatePresence( + userUid, + descriptor, + displayName, + participant, + isActive, + replaceExistingOfSameType: false); + } + + public bool TryGetPresence(string userUid, ChatChannelDescriptor channel, out ChatPresenceEntry presence) + { + var key = ChannelKey.FromDescriptor(channel); + + lock (_syncRoot) + { + if (_presenceByUser.TryGetValue(userUid, out var entries) && entries.TryGetValue(key, out presence)) + { + return true; + } + } + + presence = default; + return false; + } + + public IReadOnlyCollection GetMembers(ChatChannelDescriptor channel) + { + var key = ChannelKey.FromDescriptor(channel); + + lock (_syncRoot) + { + if (_membersByChannel.TryGetValue(key, out var members)) + { + return members.ToArray(); + } + } + + return Array.Empty(); + } + + public string RecordMessage(ChatChannelDescriptor channel, ChatParticipantInfo participant, string message, DateTime sentAtUtc) + { + var key = ChannelKey.FromDescriptor(channel); + var messageId = Guid.NewGuid().ToString("N"); + var entry = new ChatMessageLogEntry( + messageId, + channel, + sentAtUtc, + participant.Token, + participant.UserUid, + participant.User, + participant.IsLightfinder, + participant.HashedCid, + message); + + lock (_syncRoot) + { + if (!_messagesByChannel.TryGetValue(key, out var list)) + { + list = new LinkedList(); + _messagesByChannel[key] = list; + } + + var node = list.AddLast(entry); + _messageIndex[messageId] = (key, node); + + while (list.Count > MaxMessagesPerChannel) + { + var removedNode = list.First; + if (removedNode is null) + { + break; + } + + list.RemoveFirst(); + _messageIndex.Remove(removedNode.Value.MessageId); + } + } + + return messageId; + } + + public bool TryGetMessage(string messageId, out ChatMessageLogEntry entry) + { + lock (_syncRoot) + { + if (_messageIndex.TryGetValue(messageId, out var located)) + { + entry = located.Node.Value; + return true; + } + } + + entry = default; + return false; + } + + public IReadOnlyList GetRecentMessages(ChatChannelDescriptor descriptor, int maxCount) + { + lock (_syncRoot) + { + var key = ChannelKey.FromDescriptor(descriptor); + if (!_messagesByChannel.TryGetValue(key, out var list) || list.Count == 0) + { + return Array.Empty(); + } + + var take = Math.Min(maxCount, list.Count); + var result = new ChatMessageLogEntry[take]; + var node = list.Last; + for (var i = take - 1; i >= 0 && node is not null; i--) + { + result[i] = node.Value; + node = node.Previous; + } + + return result; + } + } + + public bool RemovePresence(string userUid, ChatChannelDescriptor? channel = null) + { + ArgumentException.ThrowIfNullOrEmpty(userUid); + + lock (_syncRoot) + { + if (!_presenceByUser.TryGetValue(userUid, out var entries)) + { + return false; + } + + if (channel is null) + { + foreach (var existing in entries.Keys.ToList()) + { + RemovePresenceInternal(userUid, entries, existing); + } + + _presenceByUser.Remove(userUid); + return true; + } + + var key = ChannelKey.FromDescriptor(channel.Value); + var removed = RemovePresenceInternal(userUid, entries, key); + + if (entries.Count == 0) + { + _presenceByUser.Remove(userUid); + } + + return removed; + } + } + + public bool TryResolveParticipant(ChatChannelDescriptor channel, string token, out ChatParticipantInfo participant) + { + var key = ChannelKey.FromDescriptor(channel); + + lock (_syncRoot) + { + if (_participantsByChannel.TryGetValue(key, out var participants) && + participants.TryGetValue(token, out participant)) + { + return true; + } + } + + participant = default; + return false; + } + + private ChatPresenceEntry? UpdatePresence( + string userUid, + ChatChannelDescriptor descriptor, + string displayName, + ChatParticipantInfo participant, + bool isActive, + bool replaceExistingOfSameType) + { + ArgumentException.ThrowIfNullOrEmpty(userUid); + + var normalizedDescriptor = descriptor.WithNormalizedCustomKey(); + var key = ChannelKey.FromDescriptor(normalizedDescriptor); + + lock (_syncRoot) + { + if (!_presenceByUser.TryGetValue(userUid, out var entries)) + { + if (!isActive) + return null; + + entries = new Dictionary(); + _presenceByUser[userUid] = entries; + } + + string? reusableToken = null; + + if (entries.TryGetValue(key, out var existing)) + { + reusableToken = existing.Participant.Token; + RemovePresenceInternal(userUid, entries, key); + } + + if (replaceExistingOfSameType) + { + foreach (var candidate in entries.Keys.Where(k => k.Type == key.Type).ToList()) + { + if (entries.TryGetValue(candidate, out var entry)) + { + reusableToken ??= entry.Participant.Token; + } + + RemovePresenceInternal(userUid, entries, candidate); + } + + if (!isActive) + { + if (entries.Count == 0) + { + _presenceByUser.Remove(userUid); + } + + _logger.LogDebug("Chat presence cleared for {User} ({Type})", userUid, normalizedDescriptor.Type); + return null; + } + } + else if (!isActive) + { + var removed = RemovePresenceInternal(userUid, entries, key); + if (removed) + { + _logger.LogDebug("Chat presence removed for {User} from {Channel}", userUid, Describe(key)); + } + + if (entries.Count == 0) + { + _presenceByUser.Remove(userUid); + } + + return null; + } + + var token = !string.IsNullOrEmpty(participant.Token) + ? participant.Token + : reusableToken ?? GenerateToken(); + + var finalParticipant = participant with { Token = token }; + var entryToStore = new ChatPresenceEntry( + normalizedDescriptor, + key, + displayName, + finalParticipant, + DateTime.UtcNow); + + entries[key] = entryToStore; + + if (!_membersByChannel.TryGetValue(key, out var members)) + { + members = new HashSet(StringComparer.Ordinal); + _membersByChannel[key] = members; + } + + members.Add(userUid); + + if (!_participantsByChannel.TryGetValue(key, out var participantsByToken)) + { + participantsByToken = new Dictionary(StringComparer.Ordinal); + _participantsByChannel[key] = participantsByToken; + } + + participantsByToken[token] = finalParticipant; + + _logger.LogDebug("Chat presence updated for {User} in {Channel}", userUid, Describe(key)); + return entryToStore; + } + } + + private bool RemovePresenceInternal(string userUid, Dictionary entries, ChannelKey key) + { + if (!entries.TryGetValue(key, out var existing)) + { + return false; + } + + entries.Remove(key); + + if (_membersByChannel.TryGetValue(key, out var members)) + { + members.Remove(userUid); + if (members.Count == 0) + { + _membersByChannel.Remove(key); + if (_messagesByChannel.TryGetValue(key, out var messages)) + { + foreach (var entry in messages) + { + _messageIndex.Remove(entry.MessageId); + } + + _messagesByChannel.Remove(key); + } + } + } + + if (_participantsByChannel.TryGetValue(key, out var participants)) + { + participants.Remove(existing.Participant.Token); + if (participants.Count == 0) + { + _participantsByChannel.Remove(key); + } + } + + return true; + } + + private void AddZoneDefinition(ZoneChannelDefinition definition) + { + _zoneDefinitions[definition.Key] = definition; + } + + private static string GenerateToken() + { + Span buffer = stackalloc byte[8]; + RandomNumberGenerator.Fill(buffer); + return Convert.ToHexString(buffer); + } + + private static string Describe(ChannelKey key) + => $"{key.Type}:{key.WorldId}:{key.CustomKey}"; + + private static string NormalizeKey(string? value) + => string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToUpperInvariant(); + + public readonly record struct ChatPresenceEntry( + ChatChannelDescriptor Channel, + ChannelKey ChannelKey, + string DisplayName, + ChatParticipantInfo Participant, + DateTime UpdatedAt); + + public readonly record struct ChatParticipantInfo( + string Token, + string UserUid, + UserData? User, + string? HashedCid, + bool IsLightfinder); + + public readonly record struct ChatMessageLogEntry( + string MessageId, + ChatChannelDescriptor Channel, + DateTime SentAtUtc, + string SenderToken, + string SenderUserUid, + UserData? SenderUser, + bool SenderIsLightfinder, + string? SenderHashedCid, + string Message); + + public readonly record struct ZoneChannelDefinition( + string Key, + string DisplayName, + ChatChannelDescriptor Descriptor, + IReadOnlyList TerritoryNames); + + public readonly record struct ChannelKey(ChatChannelType Type, ushort WorldId, string CustomKey) + { + public static ChannelKey FromDescriptor(ChatChannelDescriptor descriptor) + => new( + descriptor.Type, + descriptor.Type == ChatChannelType.Zone ? descriptor.WorldId : (ushort)0, + NormalizeKey(descriptor.CustomKey)); + } +} diff --git a/LightlessSyncServer/LightlessSyncServer/Startup.cs b/LightlessSyncServer/LightlessSyncServer/Startup.cs index 3b9ba70..551715a 100644 --- a/LightlessSyncServer/LightlessSyncServer/Startup.cs +++ b/LightlessSyncServer/LightlessSyncServer/Startup.cs @@ -94,6 +94,7 @@ public class Startup services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddHostedService(provider => provider.GetService()); // configure services based on main server status ConfigureServicesBasedOnShardType(services, lightlessConfig, isMainServer); diff --git a/LightlessSyncServer/LightlessSyncServices/Discord/DiscordBot.cs b/LightlessSyncServer/LightlessSyncServices/Discord/DiscordBot.cs index 4a2951f..de69b95 100644 --- a/LightlessSyncServer/LightlessSyncServices/Discord/DiscordBot.cs +++ b/LightlessSyncServer/LightlessSyncServices/Discord/DiscordBot.cs @@ -1,3 +1,9 @@ +using System.Collections.Generic; +using System; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Linq; using Discord; using Discord.Interactions; using Discord.Rest; @@ -5,6 +11,7 @@ using Discord.WebSocket; using LightlessSyncShared.Data; using LightlessSyncShared.Models; using LightlessSyncShared.Services; +using LightlessSync.API.Dto.Chat; using LightlessSyncShared.Utils.Configuration; using Microsoft.EntityFrameworkCore; using StackExchange.Redis; @@ -13,6 +20,12 @@ namespace LightlessSyncServices.Discord; internal class DiscordBot : IHostedService { + private static readonly JsonSerializerOptions ChatReportSerializerOptions = new(JsonSerializerDefaults.General) + { + PropertyNameCaseInsensitive = true + }; + private const string ChatReportButtonPrefix = "lightless-chat-report-button"; + private readonly DiscordBotServices _botServices; private readonly IConfigurationService _configurationService; private readonly IConnectionMultiplexer _connectionMultiplexer; @@ -21,7 +34,7 @@ internal class DiscordBot : IHostedService private readonly IDbContextFactory _dbContextFactory; private readonly IServiceProvider _services; private InteractionService _interactionModule; - private readonly CancellationTokenSource? _processReportQueueCts; + private CancellationTokenSource? _chatReportProcessingCts; private CancellationTokenSource? _clientConnectedCts; public DiscordBot(DiscordBotServices botServices, IServiceProvider services, IConfigurationService configuration, @@ -66,6 +79,7 @@ internal class DiscordBot : IHostedService var ctx = new SocketInteractionContext(_discordClient, x); await _interactionModule.ExecuteCommandAsync(ctx, _services).ConfigureAwait(false); }; + _discordClient.ButtonExecuted += OnChatReportButton; _discordClient.UserJoined += OnUserJoined; await _botServices.Start().ConfigureAwait(false); @@ -94,9 +108,11 @@ internal class DiscordBot : IHostedService if (!string.IsNullOrEmpty(_configurationService.GetValueOrDefault(nameof(ServicesConfiguration.DiscordBotToken), string.Empty))) { await _botServices.Stop().ConfigureAwait(false); - _processReportQueueCts?.Cancel(); + _chatReportProcessingCts?.Cancel(); + _chatReportProcessingCts?.Dispose(); _clientConnectedCts?.Cancel(); + _discordClient.ButtonExecuted -= OnChatReportButton; await _discordClient.LogoutAsync().ConfigureAwait(false); await _discordClient.StopAsync().ConfigureAwait(false); _interactionModule?.Dispose(); @@ -112,6 +128,13 @@ internal class DiscordBot : IHostedService _clientConnectedCts = new(); _ = UpdateStatusAsync(_clientConnectedCts.Token); + _chatReportProcessingCts?.Cancel(); + _chatReportProcessingCts?.Dispose(); + _chatReportProcessingCts = new(); + _ = PollChatReportsAsync(_chatReportProcessingCts.Token); + + await PublishChatReportsAsync(CancellationToken.None).ConfigureAwait(false); + await CreateOrUpdateModal(guild).ConfigureAwait(false); _botServices.UpdateGuild(guild); await _botServices.LogToChannel("Bot startup complete.").ConfigureAwait(false); @@ -120,6 +143,358 @@ internal class DiscordBot : IHostedService _ = RemoveUnregisteredUsers(_clientConnectedCts.Token); } + private async Task PollChatReportsAsync(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + try + { + await PublishChatReportsAsync(token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed while polling chat reports"); + } + + try + { + await Task.Delay(TimeSpan.FromMinutes(10), token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + } + } + + private async Task PublishChatReportsAsync(CancellationToken token) + { + var reportChannelId = _configurationService.GetValueOrDefault(nameof(ServicesConfiguration.DiscordChannelForChatReports), (ulong?)null); + if (reportChannelId is null) + { + return; + } + + var channel = await _discordClient.Rest.GetChannelAsync(reportChannelId.Value).ConfigureAwait(false) as RestTextChannel; + if (channel is null) + { + _logger.LogWarning("Configured chat report channel {ChannelId} could not be resolved.", reportChannelId); + return; + } + + using var dbContext = await _dbContextFactory.CreateDbContextAsync(token).ConfigureAwait(false); + var pendingReports = await dbContext.ReportedChatMessages + .Where(r => !r.Resolved && r.DiscordMessageId == null) + .OrderBy(r => r.ReportTimeUtc) + .Take(10) + .ToListAsync(token) + .ConfigureAwait(false); + + if (pendingReports.Count == 0) + { + return; + } + + foreach (var report in pendingReports) + { + var embed = await BuildChatReportEmbedAsync(dbContext, report, token).ConfigureAwait(false); + + var components = new ComponentBuilder() + .WithButton("Actioned", $"{ChatReportButtonPrefix}-resolve-{report.ReportId}", ButtonStyle.Danger) + .WithButton("Dismiss", $"{ChatReportButtonPrefix}-dismiss-{report.ReportId}", ButtonStyle.Secondary) + .WithButton("Ban From Chat", $"{ChatReportButtonPrefix}-banchat-{report.ReportId}", ButtonStyle.Danger); + + var postedMessage = await channel.SendMessageAsync(embed: embed.Build(), components: components.Build()).ConfigureAwait(false); + + report.DiscordMessageId = postedMessage.Id; + report.DiscordMessagePostedAtUtc = DateTime.UtcNow; + } + + await dbContext.SaveChangesAsync(token).ConfigureAwait(false); + } + + private async Task BuildChatReportEmbedAsync(LightlessDbContext dbContext, ReportedChatMessage report, CancellationToken token) + { + var reporter = await FormatUserForEmbedAsync(dbContext, report.ReporterUserUid, token).ConfigureAwait(false); + var reportedUser = await FormatUserForEmbedAsync(dbContext, report.ReportedUserUid, token).ConfigureAwait(false); + var channelDescription = await DescribeChannelAsync(dbContext, report, token).ConfigureAwait(false); + + var embed = new EmbedBuilder() + .WithTitle("Chat Report") + .WithColor(Color.DarkTeal) + .WithTimestamp(report.ReportTimeUtc) + .AddField("Report ID", report.ReportId, inline: true) + .AddField("Reporter", reporter, inline: true) + .AddField("Reported User", string.IsNullOrEmpty(reportedUser) ? "-" : reportedUser, inline: true) + .AddField("Channel", channelDescription, inline: false) + .AddField("Reason", string.IsNullOrWhiteSpace(report.Reason) ? "-" : report.Reason); + + if (!string.IsNullOrWhiteSpace(report.AdditionalContext)) + { + embed.AddField("Additional Context", report.AdditionalContext); + } + + embed.AddField("Message", $"```{Truncate(report.MessageContent, 1000)}```"); + + var snapshotPreview = BuildSnapshotPreview(report.SnapshotJson); + if (!string.IsNullOrEmpty(snapshotPreview)) + { + embed.AddField("Recent Activity", snapshotPreview); + } + + embed.WithFooter($"Message ID: {report.MessageId}"); + + return embed; + } + + private async Task DescribeChannelAsync(LightlessDbContext dbContext, ReportedChatMessage report, CancellationToken token) + { + if (report.ChannelType == ChatChannelType.Group) + { + if (!string.IsNullOrEmpty(report.ChannelKey)) + { + var group = await dbContext.Groups.AsNoTracking() + .SingleOrDefaultAsync(g => g.GID == report.ChannelKey, token) + .ConfigureAwait(false); + if (group != null) + { + var name = string.IsNullOrWhiteSpace(group.Alias) ? group.GID : group.Alias; + return $"Group: {name} ({group.GID})"; + } + } + + return $"Group: {report.ChannelKey ?? "unknown"}"; + } + + return $"Zone: {report.ChannelKey ?? "unknown"} (World {report.WorldId}, Zone {report.ZoneId})"; + } + + private async Task FormatUserForEmbedAsync(LightlessDbContext dbContext, string? userUid, CancellationToken token) + { + if (string.IsNullOrEmpty(userUid)) + { + return "-"; + } + + var user = await dbContext.Users.AsNoTracking() + .SingleOrDefaultAsync(u => u.UID == userUid, token) + .ConfigureAwait(false); + + var display = user?.Alias ?? user?.UID ?? userUid; + + var lodestone = await dbContext.LodeStoneAuth + .Include(l => l.User) + .AsNoTracking() + .SingleOrDefaultAsync(l => l.User != null && l.User.UID == userUid, token) + .ConfigureAwait(false); + + if (lodestone != null) + { + display = $"{display} (<@{lodestone.DiscordId}>)"; + } + + return display; + } + + private string BuildSnapshotPreview(string snapshotJson) + { + if (string.IsNullOrWhiteSpace(snapshotJson)) + { + return string.Empty; + } + + try + { + var snapshot = JsonSerializer.Deserialize>(snapshotJson, ChatReportSerializerOptions); + if (snapshot is null || snapshot.Count == 0) + { + return string.Empty; + } + + var builder = new StringBuilder(); + foreach (var item in snapshot.TakeLast(5)) + { + var sender = item.SenderAlias ?? item.SenderUserUid; + builder.AppendLine($"{item.SentAtUtc:HH\\:mm} {sender}: {Truncate(item.Message, 120)}"); + } + + return $"```{builder.ToString().TrimEnd()}```"; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to parse chat report snapshot"); + return string.Empty; + } + } + + private static string Truncate(string value, int maxLength) + { + if (string.IsNullOrEmpty(value) || value.Length <= maxLength) + { + return value; + } + + return value[..maxLength] + "..."; + } + + private async Task OnChatReportButton(SocketMessageComponent arg) + { + if (!arg.Data.CustomId.StartsWith(ChatReportButtonPrefix, StringComparison.Ordinal)) + { + return; + } + + if (arg.GuildId is null) + { + await arg.RespondAsync("This action is only available inside the server.", ephemeral: true).ConfigureAwait(false); + return; + } + + var guild = _discordClient.GetGuild(arg.GuildId.Value); + if (guild is null) + { + await arg.RespondAsync("Unable to resolve the guild for this interaction.", ephemeral: true).ConfigureAwait(false); + return; + } + + var guildUser = guild.GetUser(arg.User.Id); + if (guildUser is null || !(guildUser.GuildPermissions.ManageMessages || guildUser.GuildPermissions.BanMembers || guildUser.GuildPermissions.Administrator)) + { + await arg.RespondAsync("You do not have permission to resolve chat reports.", ephemeral: true).ConfigureAwait(false); + return; + } + + var parts = arg.Data.CustomId.Split('-', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 5 || !int.TryParse(parts[^1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var reportId)) + { + await arg.RespondAsync("Invalid report action.", ephemeral: true).ConfigureAwait(false); + return; + } + + var action = parts[^2]; + + await using var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false); + var report = await dbContext.ReportedChatMessages.SingleOrDefaultAsync(r => r.ReportId == reportId).ConfigureAwait(false); + if (report is null) + { + await arg.RespondAsync("This report could not be found.", ephemeral: true).ConfigureAwait(false); + return; + } + + if (report.Resolved) + { + await arg.RespondAsync("This report has already been processed.", ephemeral: true).ConfigureAwait(false); + return; + } + + string resolutionLabel; + switch (action) + { + case "resolve": + resolutionLabel = "Actioned"; + break; + case "dismiss": + resolutionLabel = "Dismissed"; + break; + case "banchat": + resolutionLabel = "Chat access revoked"; + if (!string.IsNullOrEmpty(report.ReportedUserUid)) + { + var targetUser = await dbContext.Users.SingleOrDefaultAsync(u => u.UID == report.ReportedUserUid).ConfigureAwait(false); + if (targetUser is not null && !targetUser.ChatBanned) + { + targetUser.ChatBanned = true; + dbContext.Update(targetUser); + } + } + break; + default: + await arg.RespondAsync("Unknown action.", ephemeral: true).ConfigureAwait(false); + return; + } + + try + { + await UpdateChatReportMessageAsync(report, action, guildUser).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to update Discord message for resolved report {ReportId}", report.ReportId); + } + + dbContext.ReportedChatMessages.Remove(report); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + + string responseText = action switch + { + "resolve" => "actioned", + "dismiss" => "dismissed", + "banchat" => "chat access revoked", + _ => "processed" + }; + + await arg.RespondAsync($"Report {report.ReportId} {responseText}.", ephemeral: true).ConfigureAwait(false); + } + + private async Task UpdateChatReportMessageAsync(ReportedChatMessage report, string action, SocketGuildUser moderator) + { + if (report.DiscordMessageId is null) + { + return; + } + + var reportChannelId = _configurationService.GetValueOrDefault(nameof(ServicesConfiguration.DiscordChannelForChatReports), (ulong?)null); + if (reportChannelId is null) + { + return; + } + + var channel = await _discordClient.Rest.GetChannelAsync(reportChannelId.Value).ConfigureAwait(false) as RestTextChannel; + if (channel is null) + { + return; + } + + var message = await channel.GetMessageAsync(report.DiscordMessageId.Value).ConfigureAwait(false) as IUserMessage; + if (message is null) + { + return; + } + + var existingEmbed = message.Embeds.FirstOrDefault(); + var embedBuilder = existingEmbed is Embed richEmbed + ? richEmbed.ToEmbedBuilder() + : new EmbedBuilder().WithTitle("Chat Report"); + + embedBuilder.Fields.RemoveAll(f => string.Equals(f.Name, "Resolution", StringComparison.OrdinalIgnoreCase)); + var resolutionText = action switch + { + "resolve" => "Actioned", + "dismiss" => "Dismissed", + "banchat" => "Chat access revoked", + _ => "Processed" + }; + var resolutionColor = action switch + { + "resolve" => Color.DarkRed, + "dismiss" => Color.Green, + "banchat" => Color.DarkRed, + _ => Color.LightGrey + }; + embedBuilder.AddField("Resolution", $"{resolutionText} by {moderator.Mention} at "); + embedBuilder.WithColor(resolutionColor); + + await message.ModifyAsync(props => + { + props.Embed = embedBuilder.Build(); + props.Components = new ComponentBuilder().Build(); + }).ConfigureAwait(false); + } + private async Task UpdateVanityRoles(RestGuild guild, CancellationToken token) { while (!token.IsCancellationRequested) @@ -488,6 +863,15 @@ internal class DiscordBot : IHostedService } } + private sealed record ChatReportSnapshotItem( + string MessageId, + DateTime SentAtUtc, + string SenderUserUid, + string? SenderAlias, + bool SenderIsLightfinder, + string? SenderHashedCid, + string Message); + private async Task UpdateStatusAsync(CancellationToken token) { while (!token.IsCancellationRequested) @@ -500,4 +884,4 @@ internal class DiscordBot : IHostedService await Task.Delay(TimeSpan.FromSeconds(10)).ConfigureAwait(false); } } -} \ No newline at end of file +} diff --git a/LightlessSyncServer/LightlessSyncShared/Data/MareDbContext.cs b/LightlessSyncServer/LightlessSyncShared/Data/MareDbContext.cs index fe17706..e96d561 100644 --- a/LightlessSyncServer/LightlessSyncShared/Data/MareDbContext.cs +++ b/LightlessSyncServer/LightlessSyncShared/Data/MareDbContext.cs @@ -45,6 +45,7 @@ public class LightlessDbContext : DbContext public DbSet UserProfileData { get; set; } public DbSet Users { get; set; } public DbSet Permissions { get; set; } + public DbSet ReportedChatMessages { get; set; } public DbSet GroupPairPreferredPermissions { get; set; } public DbSet UserDefaultPreferredPermissions { get; set; } public DbSet CharaData { get; set; } @@ -153,5 +154,11 @@ public class LightlessDbContext : DbContext mb.Entity().HasIndex(c => c.ParentId); mb.Entity().HasOne(u => u.AllowedGroup).WithMany().HasForeignKey(u => u.AllowedGroupGID).OnDelete(DeleteBehavior.Cascade); mb.Entity().HasOne(u => u.AllowedUser).WithMany().HasForeignKey(u => u.AllowedUserUID).OnDelete(DeleteBehavior.Cascade); + + mb.Entity().ToTable("reported_chat_messages"); + mb.Entity().HasIndex(r => r.ReporterUserUid); + mb.Entity().HasIndex(r => r.ReportedUserUid); + mb.Entity().HasIndex(r => r.MessageId).IsUnique(); + mb.Entity().HasIndex(r => r.DiscordMessageId); } } \ No newline at end of file diff --git a/LightlessSyncServer/LightlessSyncShared/Migrations/20251028215549_ChatReports.Designer.cs b/LightlessSyncServer/LightlessSyncShared/Migrations/20251028215549_ChatReports.Designer.cs new file mode 100644 index 0000000..f20287c --- /dev/null +++ b/LightlessSyncServer/LightlessSyncShared/Migrations/20251028215549_ChatReports.Designer.cs @@ -0,0 +1,1318 @@ +// +using System; +using LightlessSyncShared.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LightlessSyncServer.Migrations +{ + [DbContext(typeof(LightlessDbContext))] + [Migration("20251028215549_ChatReports")] + partial class ChatReports + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LightlessSyncShared.Models.Auth", b => + { + b.Property("HashedKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("hashed_key"); + + b.Property("IsBanned") + .HasColumnType("boolean") + .HasColumnName("is_banned"); + + b.Property("MarkForBan") + .HasColumnType("boolean") + .HasColumnName("mark_for_ban"); + + b.Property("PrimaryUserUID") + .HasColumnType("character varying(10)") + .HasColumnName("primary_user_uid"); + + b.Property("UserUID") + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.HasKey("HashedKey") + .HasName("pk_auth"); + + b.HasIndex("PrimaryUserUID") + .HasDatabaseName("ix_auth_primary_user_uid"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_auth_user_uid"); + + b.ToTable("auth", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.Banned", b => + { + b.Property("CharacterIdentification") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("character_identification"); + + b.Property("BannedUid") + .HasColumnType("text") + .HasColumnName("banned_uid"); + + b.Property("Reason") + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.HasKey("CharacterIdentification") + .HasName("pk_banned_users"); + + b.ToTable("banned_users", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.BannedRegistrations", b => + { + b.Property("DiscordIdOrLodestoneAuth") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("discord_id_or_lodestone_auth"); + + b.HasKey("DiscordIdOrLodestoneAuth") + .HasName("pk_banned_registrations"); + + b.ToTable("banned_registrations", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaData", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("UploaderUID") + .HasColumnType("character varying(10)") + .HasColumnName("uploader_uid"); + + b.Property("AccessType") + .HasColumnType("integer") + .HasColumnName("access_type"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_date"); + + b.Property("CustomizeData") + .HasColumnType("text") + .HasColumnName("customize_data"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("DownloadCount") + .HasColumnType("integer") + .HasColumnName("download_count"); + + b.Property("ExpiryDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiry_date"); + + b.Property("GlamourerData") + .HasColumnType("text") + .HasColumnName("glamourer_data"); + + b.Property("ManipulationData") + .HasColumnType("text") + .HasColumnName("manipulation_data"); + + b.Property("ShareType") + .HasColumnType("integer") + .HasColumnName("share_type"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_date"); + + b.HasKey("Id", "UploaderUID") + .HasName("pk_chara_data"); + + b.HasIndex("Id") + .HasDatabaseName("ix_chara_data_id"); + + b.HasIndex("UploaderUID") + .HasDatabaseName("ix_chara_data_uploader_uid"); + + b.ToTable("chara_data", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataAllowance", b => + { + b.Property("ParentId") + .HasColumnType("text") + .HasColumnName("parent_id"); + + b.Property("ParentUploaderUID") + .HasColumnType("character varying(10)") + .HasColumnName("parent_uploader_uid"); + + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowedGroupGID") + .HasColumnType("character varying(20)") + .HasColumnName("allowed_group_gid"); + + b.Property("AllowedUserUID") + .HasColumnType("character varying(10)") + .HasColumnName("allowed_user_uid"); + + b.HasKey("ParentId", "ParentUploaderUID", "Id") + .HasName("pk_chara_data_allowance"); + + b.HasIndex("AllowedGroupGID") + .HasDatabaseName("ix_chara_data_allowance_allowed_group_gid"); + + b.HasIndex("AllowedUserUID") + .HasDatabaseName("ix_chara_data_allowance_allowed_user_uid"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_chara_data_allowance_parent_id"); + + b.ToTable("chara_data_allowance", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataFile", b => + { + b.Property("ParentId") + .HasColumnType("text") + .HasColumnName("parent_id"); + + b.Property("ParentUploaderUID") + .HasColumnType("character varying(10)") + .HasColumnName("parent_uploader_uid"); + + b.Property("GamePath") + .HasColumnType("text") + .HasColumnName("game_path"); + + b.Property("FileCacheHash") + .HasColumnType("character varying(40)") + .HasColumnName("file_cache_hash"); + + b.HasKey("ParentId", "ParentUploaderUID", "GamePath") + .HasName("pk_chara_data_files"); + + b.HasIndex("FileCacheHash") + .HasDatabaseName("ix_chara_data_files_file_cache_hash"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_chara_data_files_parent_id"); + + b.ToTable("chara_data_files", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataFileSwap", b => + { + b.Property("ParentId") + .HasColumnType("text") + .HasColumnName("parent_id"); + + b.Property("ParentUploaderUID") + .HasColumnType("character varying(10)") + .HasColumnName("parent_uploader_uid"); + + b.Property("GamePath") + .HasColumnType("text") + .HasColumnName("game_path"); + + b.Property("FilePath") + .HasColumnType("text") + .HasColumnName("file_path"); + + b.HasKey("ParentId", "ParentUploaderUID", "GamePath") + .HasName("pk_chara_data_file_swaps"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_chara_data_file_swaps_parent_id"); + + b.ToTable("chara_data_file_swaps", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataOriginalFile", b => + { + b.Property("ParentId") + .HasColumnType("text") + .HasColumnName("parent_id"); + + b.Property("ParentUploaderUID") + .HasColumnType("character varying(10)") + .HasColumnName("parent_uploader_uid"); + + b.Property("GamePath") + .HasColumnType("text") + .HasColumnName("game_path"); + + b.Property("Hash") + .HasColumnType("text") + .HasColumnName("hash"); + + b.HasKey("ParentId", "ParentUploaderUID", "GamePath") + .HasName("pk_chara_data_orig_files"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_chara_data_orig_files_parent_id"); + + b.ToTable("chara_data_orig_files", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataPose", b => + { + b.Property("ParentId") + .HasColumnType("text") + .HasColumnName("parent_id"); + + b.Property("ParentUploaderUID") + .HasColumnType("character varying(10)") + .HasColumnName("parent_uploader_uid"); + + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("PoseData") + .HasColumnType("text") + .HasColumnName("pose_data"); + + b.Property("WorldData") + .HasColumnType("text") + .HasColumnName("world_data"); + + b.HasKey("ParentId", "ParentUploaderUID", "Id") + .HasName("pk_chara_data_poses"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_chara_data_poses_parent_id"); + + b.ToTable("chara_data_poses", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.ClientPair", b => + { + b.Property("UserUID") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.Property("OtherUserUID") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("other_user_uid"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.HasKey("UserUID", "OtherUserUID") + .HasName("pk_client_pairs"); + + b.HasIndex("OtherUserUID") + .HasDatabaseName("ix_client_pairs_other_user_uid"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_client_pairs_user_uid"); + + b.ToTable("client_pairs", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.FileCache", b => + { + b.Property("Hash") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("hash"); + + b.Property("RawSize") + .HasColumnType("bigint") + .HasColumnName("raw_size"); + + b.Property("Size") + .HasColumnType("bigint") + .HasColumnName("size"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.Property("UploadDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("upload_date"); + + b.Property("Uploaded") + .HasColumnType("boolean") + .HasColumnName("uploaded"); + + b.Property("UploaderUID") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("uploader_uid"); + + b.HasKey("Hash") + .HasName("pk_file_caches"); + + b.HasIndex("UploaderUID") + .HasDatabaseName("ix_file_caches_uploader_uid"); + + b.ToTable("file_caches", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.ForbiddenUploadEntry", b => + { + b.Property("Hash") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("hash"); + + b.Property("ForbiddenBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("forbidden_by"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.HasKey("Hash") + .HasName("pk_forbidden_upload_entries"); + + b.ToTable("forbidden_upload_entries", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.Group", b => + { + b.Property("GID") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("gid"); + + b.Property("Alias") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("alias"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_date") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("HashedPassword") + .HasColumnType("text") + .HasColumnName("hashed_password"); + + b.Property("InvitesEnabled") + .HasColumnType("boolean") + .HasColumnName("invites_enabled"); + + b.Property("OwnerUID") + .HasColumnType("character varying(10)") + .HasColumnName("owner_uid"); + + b.Property("PreferDisableAnimations") + .HasColumnType("boolean") + .HasColumnName("prefer_disable_animations"); + + b.Property("PreferDisableSounds") + .HasColumnType("boolean") + .HasColumnName("prefer_disable_sounds"); + + b.Property("PreferDisableVFX") + .HasColumnType("boolean") + .HasColumnName("prefer_disable_vfx"); + + b.HasKey("GID") + .HasName("pk_groups"); + + b.HasIndex("OwnerUID") + .HasDatabaseName("ix_groups_owner_uid"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupBan", b => + { + b.Property("GroupGID") + .HasColumnType("character varying(20)") + .HasColumnName("group_gid"); + + b.Property("BannedUserUID") + .HasColumnType("character varying(10)") + .HasColumnName("banned_user_uid"); + + b.Property("BannedByUID") + .HasColumnType("character varying(10)") + .HasColumnName("banned_by_uid"); + + b.Property("BannedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("banned_on"); + + b.Property("BannedReason") + .HasColumnType("text") + .HasColumnName("banned_reason"); + + b.HasKey("GroupGID", "BannedUserUID") + .HasName("pk_group_bans"); + + b.HasIndex("BannedByUID") + .HasDatabaseName("ix_group_bans_banned_by_uid"); + + b.HasIndex("BannedUserUID") + .HasDatabaseName("ix_group_bans_banned_user_uid"); + + b.HasIndex("GroupGID") + .HasDatabaseName("ix_group_bans_group_gid"); + + b.ToTable("group_bans", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupPair", b => + { + b.Property("GroupGID") + .HasColumnType("character varying(20)") + .HasColumnName("group_gid"); + + b.Property("GroupUserUID") + .HasColumnType("character varying(10)") + .HasColumnName("group_user_uid"); + + b.Property("FromFinder") + .HasColumnType("boolean") + .HasColumnName("from_finder"); + + b.Property("IsModerator") + .HasColumnType("boolean") + .HasColumnName("is_moderator"); + + b.Property("IsPinned") + .HasColumnType("boolean") + .HasColumnName("is_pinned"); + + b.Property("JoinedGroupOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_group_on"); + + b.HasKey("GroupGID", "GroupUserUID") + .HasName("pk_group_pairs"); + + b.HasIndex("GroupGID") + .HasDatabaseName("ix_group_pairs_group_gid"); + + b.HasIndex("GroupUserUID") + .HasDatabaseName("ix_group_pairs_group_user_uid"); + + b.ToTable("group_pairs", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupPairPreferredPermission", b => + { + b.Property("UserUID") + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.Property("GroupGID") + .HasColumnType("character varying(20)") + .HasColumnName("group_gid"); + + b.Property("DisableAnimations") + .HasColumnType("boolean") + .HasColumnName("disable_animations"); + + b.Property("DisableSounds") + .HasColumnType("boolean") + .HasColumnName("disable_sounds"); + + b.Property("DisableVFX") + .HasColumnType("boolean") + .HasColumnName("disable_vfx"); + + b.Property("IsPaused") + .HasColumnType("boolean") + .HasColumnName("is_paused"); + + b.HasKey("UserUID", "GroupGID") + .HasName("pk_group_pair_preferred_permissions"); + + b.HasIndex("GroupGID") + .HasDatabaseName("ix_group_pair_preferred_permissions_group_gid"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_group_pair_preferred_permissions_user_uid"); + + b.ToTable("group_pair_preferred_permissions", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupProfile", b => + { + b.Property("GroupGID") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("group_gid"); + + b.Property("Base64GroupBannerImage") + .HasColumnType("text") + .HasColumnName("base64group_banner_image"); + + b.Property("Base64GroupProfileImage") + .HasColumnType("text") + .HasColumnName("base64group_profile_image"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsNSFW") + .HasColumnType("boolean") + .HasColumnName("is_nsfw"); + + b.Property("ProfileDisabled") + .HasColumnType("boolean") + .HasColumnName("profile_disabled"); + + b.PrimitiveCollection("Tags") + .HasColumnType("integer[]") + .HasColumnName("tags"); + + b.HasKey("GroupGID") + .HasName("pk_group_profiles"); + + b.HasIndex("GroupGID") + .HasDatabaseName("ix_group_profiles_group_gid"); + + b.ToTable("group_profiles", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupTempInvite", b => + { + b.Property("GroupGID") + .HasColumnType("character varying(20)") + .HasColumnName("group_gid"); + + b.Property("Invite") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("invite"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_date"); + + b.HasKey("GroupGID", "Invite") + .HasName("pk_group_temp_invites"); + + b.HasIndex("GroupGID") + .HasDatabaseName("ix_group_temp_invites_group_gid"); + + b.HasIndex("Invite") + .HasDatabaseName("ix_group_temp_invites_invite"); + + b.ToTable("group_temp_invites", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.LodeStoneAuth", b => + { + b.Property("DiscordId") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(20,0)") + .HasColumnName("discord_id"); + + b.Property("HashedLodestoneId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("hashed_lodestone_id"); + + b.Property("LodestoneAuthString") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("lodestone_auth_string"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("UserUID") + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.HasKey("DiscordId") + .HasName("pk_lodestone_auth"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_lodestone_auth_user_uid"); + + b.ToTable("lodestone_auth", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.ReportedChatMessage", b => + { + b.Property("ReportId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("report_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReportId")); + + b.Property("AdditionalContext") + .HasColumnType("text") + .HasColumnName("additional_context"); + + b.Property("ChannelKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("channel_key"); + + b.Property("ChannelType") + .HasColumnType("smallint") + .HasColumnName("channel_type"); + + b.Property("DiscordMessageId") + .HasColumnType("numeric(20,0)") + .HasColumnName("discord_message_id"); + + b.Property("DiscordMessagePostedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("discord_message_posted_at_utc"); + + b.Property("MessageContent") + .IsRequired() + .HasColumnType("text") + .HasColumnName("message_content"); + + b.Property("MessageId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("message_id"); + + b.Property("MessageSentAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("message_sent_at_utc"); + + b.Property("Reason") + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("ReportTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("report_time_utc"); + + b.Property("ReportedUserUid") + .HasColumnType("text") + .HasColumnName("reported_user_uid"); + + b.Property("ReporterUserUid") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reporter_user_uid"); + + b.Property("ResolutionNotes") + .HasColumnType("text") + .HasColumnName("resolution_notes"); + + b.Property("Resolved") + .HasColumnType("boolean") + .HasColumnName("resolved"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at_utc"); + + b.Property("ResolvedByUserUid") + .HasColumnType("text") + .HasColumnName("resolved_by_user_uid"); + + b.Property("SenderDisplayName") + .HasColumnType("text") + .HasColumnName("sender_display_name"); + + b.Property("SenderHashedCid") + .HasColumnType("text") + .HasColumnName("sender_hashed_cid"); + + b.Property("SenderToken") + .IsRequired() + .HasColumnType("text") + .HasColumnName("sender_token"); + + b.Property("SenderWasLightfinder") + .HasColumnType("boolean") + .HasColumnName("sender_was_lightfinder"); + + b.Property("SnapshotJson") + .HasColumnType("text") + .HasColumnName("snapshot_json"); + + b.Property("WorldId") + .HasColumnType("integer") + .HasColumnName("world_id"); + + b.Property("ZoneId") + .HasColumnType("integer") + .HasColumnName("zone_id"); + + b.HasKey("ReportId") + .HasName("pk_reported_chat_messages"); + + b.HasIndex("DiscordMessageId") + .HasDatabaseName("ix_reported_chat_messages_discord_message_id"); + + b.HasIndex("MessageId") + .IsUnique() + .HasDatabaseName("ix_reported_chat_messages_message_id"); + + b.HasIndex("ReportedUserUid") + .HasDatabaseName("ix_reported_chat_messages_reported_user_uid"); + + b.HasIndex("ReporterUserUid") + .HasDatabaseName("ix_reported_chat_messages_reporter_user_uid"); + + b.ToTable("reported_chat_messages", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.User", b => + { + b.Property("UID") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("uid"); + + b.Property("Alias") + .HasMaxLength(15) + .HasColumnType("character varying(15)") + .HasColumnName("alias"); + + b.Property("HasVanity") + .HasColumnType("boolean") + .HasColumnName("has_vanity"); + + b.Property("ChatBanned") + .HasColumnType("boolean") + .HasColumnName("chat_banned"); + + b.Property("IsAdmin") + .HasColumnType("boolean") + .HasColumnName("is_admin"); + + b.Property("IsModerator") + .HasColumnType("boolean") + .HasColumnName("is_moderator"); + + b.Property("LastLoggedIn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_logged_in"); + + b.Property("TextColorHex") + .HasMaxLength(9) + .HasColumnType("character varying(9)") + .HasColumnName("text_color_hex"); + + b.Property("TextGlowColorHex") + .HasMaxLength(9) + .HasColumnType("character varying(9)") + .HasColumnName("text_glow_color_hex"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.HasKey("UID") + .HasName("pk_users"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.UserDefaultPreferredPermission", b => + { + b.Property("UserUID") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.Property("DisableGroupAnimations") + .HasColumnType("boolean") + .HasColumnName("disable_group_animations"); + + b.Property("DisableGroupSounds") + .HasColumnType("boolean") + .HasColumnName("disable_group_sounds"); + + b.Property("DisableGroupVFX") + .HasColumnType("boolean") + .HasColumnName("disable_group_vfx"); + + b.Property("DisableIndividualAnimations") + .HasColumnType("boolean") + .HasColumnName("disable_individual_animations"); + + b.Property("DisableIndividualSounds") + .HasColumnType("boolean") + .HasColumnName("disable_individual_sounds"); + + b.Property("DisableIndividualVFX") + .HasColumnType("boolean") + .HasColumnName("disable_individual_vfx"); + + b.Property("IndividualIsSticky") + .HasColumnType("boolean") + .HasColumnName("individual_is_sticky"); + + b.HasKey("UserUID") + .HasName("pk_user_default_preferred_permissions"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_user_default_preferred_permissions_user_uid"); + + b.ToTable("user_default_preferred_permissions", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.UserPermissionSet", b => + { + b.Property("UserUID") + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.Property("OtherUserUID") + .HasColumnType("character varying(10)") + .HasColumnName("other_user_uid"); + + b.Property("DisableAnimations") + .HasColumnType("boolean") + .HasColumnName("disable_animations"); + + b.Property("DisableSounds") + .HasColumnType("boolean") + .HasColumnName("disable_sounds"); + + b.Property("DisableVFX") + .HasColumnType("boolean") + .HasColumnName("disable_vfx"); + + b.Property("IsPaused") + .HasColumnType("boolean") + .HasColumnName("is_paused"); + + b.Property("Sticky") + .HasColumnType("boolean") + .HasColumnName("sticky"); + + b.HasKey("UserUID", "OtherUserUID") + .HasName("pk_user_permission_sets"); + + b.HasIndex("OtherUserUID") + .HasDatabaseName("ix_user_permission_sets_other_user_uid"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_user_permission_sets_user_uid"); + + b.HasIndex("UserUID", "OtherUserUID", "IsPaused") + .HasDatabaseName("ix_user_permission_sets_user_uid_other_user_uid_is_paused"); + + b.ToTable("user_permission_sets", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.UserProfileData", b => + { + b.Property("UserUID") + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.Property("Base64BannerImage") + .HasColumnType("text") + .HasColumnName("base64banner_image"); + + b.Property("Base64ProfileImage") + .HasColumnType("text") + .HasColumnName("base64profile_image"); + + b.Property("FlaggedForReport") + .HasColumnType("boolean") + .HasColumnName("flagged_for_report"); + + b.Property("IsNSFW") + .HasColumnType("boolean") + .HasColumnName("is_nsfw"); + + b.Property("ProfileDisabled") + .HasColumnType("boolean") + .HasColumnName("profile_disabled"); + + b.PrimitiveCollection("Tags") + .HasColumnType("integer[]") + .HasColumnName("tags"); + + b.Property("UserDescription") + .HasColumnType("text") + .HasColumnName("user_description"); + + b.HasKey("UserUID") + .HasName("pk_user_profile_data"); + + b.ToTable("user_profile_data", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.Auth", b => + { + b.HasOne("LightlessSyncShared.Models.User", "PrimaryUser") + .WithMany() + .HasForeignKey("PrimaryUserUID") + .HasConstraintName("fk_auth_users_primary_user_uid"); + + b.HasOne("LightlessSyncShared.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .HasConstraintName("fk_auth_users_user_uid"); + + b.Navigation("PrimaryUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaData", b => + { + b.HasOne("LightlessSyncShared.Models.User", "Uploader") + .WithMany() + .HasForeignKey("UploaderUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chara_data_users_uploader_uid"); + + b.Navigation("Uploader"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataAllowance", b => + { + b.HasOne("LightlessSyncShared.Models.Group", "AllowedGroup") + .WithMany() + .HasForeignKey("AllowedGroupGID") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_chara_data_allowance_groups_allowed_group_gid"); + + b.HasOne("LightlessSyncShared.Models.User", "AllowedUser") + .WithMany() + .HasForeignKey("AllowedUserUID") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_chara_data_allowance_users_allowed_user_uid"); + + b.HasOne("LightlessSyncShared.Models.CharaData", "Parent") + .WithMany("AllowedIndividiuals") + .HasForeignKey("ParentId", "ParentUploaderUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chara_data_allowance_chara_data_parent_id_parent_uploader_u"); + + b.Navigation("AllowedGroup"); + + b.Navigation("AllowedUser"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataFile", b => + { + b.HasOne("LightlessSyncShared.Models.FileCache", "FileCache") + .WithMany() + .HasForeignKey("FileCacheHash") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_chara_data_files_files_file_cache_hash"); + + b.HasOne("LightlessSyncShared.Models.CharaData", "Parent") + .WithMany("Files") + .HasForeignKey("ParentId", "ParentUploaderUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chara_data_files_chara_data_parent_id_parent_uploader_uid"); + + b.Navigation("FileCache"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataFileSwap", b => + { + b.HasOne("LightlessSyncShared.Models.CharaData", "Parent") + .WithMany("FileSwaps") + .HasForeignKey("ParentId", "ParentUploaderUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chara_data_file_swaps_chara_data_parent_id_parent_uploader_"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataOriginalFile", b => + { + b.HasOne("LightlessSyncShared.Models.CharaData", "Parent") + .WithMany("OriginalFiles") + .HasForeignKey("ParentId", "ParentUploaderUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chara_data_orig_files_chara_data_parent_id_parent_uploader_"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataPose", b => + { + b.HasOne("LightlessSyncShared.Models.CharaData", "Parent") + .WithMany("Poses") + .HasForeignKey("ParentId", "ParentUploaderUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chara_data_poses_chara_data_parent_id_parent_uploader_uid"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.ClientPair", b => + { + b.HasOne("LightlessSyncShared.Models.User", "OtherUser") + .WithMany() + .HasForeignKey("OtherUserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_client_pairs_users_other_user_uid"); + + b.HasOne("LightlessSyncShared.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_client_pairs_users_user_uid"); + + b.Navigation("OtherUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.FileCache", b => + { + b.HasOne("LightlessSyncShared.Models.User", "Uploader") + .WithMany() + .HasForeignKey("UploaderUID") + .HasConstraintName("fk_file_caches_users_uploader_uid"); + + b.Navigation("Uploader"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.Group", b => + { + b.HasOne("LightlessSyncShared.Models.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUID") + .HasConstraintName("fk_groups_users_owner_uid"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupBan", b => + { + b.HasOne("LightlessSyncShared.Models.User", "BannedBy") + .WithMany() + .HasForeignKey("BannedByUID") + .HasConstraintName("fk_group_bans_users_banned_by_uid"); + + b.HasOne("LightlessSyncShared.Models.User", "BannedUser") + .WithMany() + .HasForeignKey("BannedUserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_bans_users_banned_user_uid"); + + b.HasOne("LightlessSyncShared.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupGID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_bans_groups_group_gid"); + + b.Navigation("BannedBy"); + + b.Navigation("BannedUser"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupPair", b => + { + b.HasOne("LightlessSyncShared.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupGID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_pairs_groups_group_gid"); + + b.HasOne("LightlessSyncShared.Models.User", "GroupUser") + .WithMany() + .HasForeignKey("GroupUserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_pairs_users_group_user_uid"); + + b.Navigation("Group"); + + b.Navigation("GroupUser"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupPairPreferredPermission", b => + { + b.HasOne("LightlessSyncShared.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupGID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_pair_preferred_permissions_groups_group_gid"); + + b.HasOne("LightlessSyncShared.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_pair_preferred_permissions_users_user_uid"); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupProfile", b => + { + b.HasOne("LightlessSyncShared.Models.Group", "Group") + .WithOne("Profile") + .HasForeignKey("LightlessSyncShared.Models.GroupProfile", "GroupGID") + .HasConstraintName("fk_group_profiles_groups_group_gid"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupTempInvite", b => + { + b.HasOne("LightlessSyncShared.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupGID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_temp_invites_groups_group_gid"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.LodeStoneAuth", b => + { + b.HasOne("LightlessSyncShared.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .HasConstraintName("fk_lodestone_auth_users_user_uid"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.UserDefaultPreferredPermission", b => + { + b.HasOne("LightlessSyncShared.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_default_preferred_permissions_users_user_uid"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.UserPermissionSet", b => + { + b.HasOne("LightlessSyncShared.Models.User", "OtherUser") + .WithMany() + .HasForeignKey("OtherUserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_permission_sets_users_other_user_uid"); + + b.HasOne("LightlessSyncShared.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_permission_sets_users_user_uid"); + + b.Navigation("OtherUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.UserProfileData", b => + { + b.HasOne("LightlessSyncShared.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_profile_data_users_user_uid"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaData", b => + { + b.Navigation("AllowedIndividiuals"); + + b.Navigation("FileSwaps"); + + b.Navigation("Files"); + + b.Navigation("OriginalFiles"); + + b.Navigation("Poses"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.Group", b => + { + b.Navigation("Profile"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/LightlessSyncServer/LightlessSyncShared/Migrations/20251028215549_ChatReports.cs b/LightlessSyncServer/LightlessSyncShared/Migrations/20251028215549_ChatReports.cs new file mode 100644 index 0000000..6f0123f --- /dev/null +++ b/LightlessSyncServer/LightlessSyncShared/Migrations/20251028215549_ChatReports.cs @@ -0,0 +1,90 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LightlessSyncServer.Migrations +{ + /// + public partial class ChatReports : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "reported_chat_messages", + columns: table => new + { + report_id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + report_time_utc = table.Column(type: "timestamp with time zone", nullable: false), + reporter_user_uid = table.Column(type: "text", nullable: false), + reported_user_uid = table.Column(type: "text", nullable: true), + channel_type = table.Column(type: "smallint", nullable: false), + world_id = table.Column(type: "integer", nullable: false), + zone_id = table.Column(type: "integer", nullable: false), + channel_key = table.Column(type: "text", nullable: false), + message_id = table.Column(type: "text", nullable: false), + message_sent_at_utc = table.Column(type: "timestamp with time zone", nullable: false), + message_content = table.Column(type: "text", nullable: false), + sender_token = table.Column(type: "text", nullable: false), + sender_hashed_cid = table.Column(type: "text", nullable: true), + sender_display_name = table.Column(type: "text", nullable: true), + sender_was_lightfinder = table.Column(type: "boolean", nullable: false), + snapshot_json = table.Column(type: "text", nullable: true), + reason = table.Column(type: "text", nullable: true), + additional_context = table.Column(type: "text", nullable: true), + discord_message_id = table.Column(type: "numeric(20,0)", nullable: true), + discord_message_posted_at_utc = table.Column(type: "timestamp with time zone", nullable: true), + resolved = table.Column(type: "boolean", nullable: false), + resolved_at_utc = table.Column(type: "timestamp with time zone", nullable: true), + resolution_notes = table.Column(type: "text", nullable: true), + resolved_by_user_uid = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_reported_chat_messages", x => x.report_id); + }); + + migrationBuilder.AddColumn( + name: "chat_banned", + table: "users", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateIndex( + name: "ix_reported_chat_messages_discord_message_id", + table: "reported_chat_messages", + column: "discord_message_id"); + + migrationBuilder.CreateIndex( + name: "ix_reported_chat_messages_message_id", + table: "reported_chat_messages", + column: "message_id", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_reported_chat_messages_reported_user_uid", + table: "reported_chat_messages", + column: "reported_user_uid"); + + migrationBuilder.CreateIndex( + name: "ix_reported_chat_messages_reporter_user_uid", + table: "reported_chat_messages", + column: "reporter_user_uid"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "reported_chat_messages"); + + migrationBuilder.DropColumn( + name: "chat_banned", + table: "users"); + } + } +} diff --git a/LightlessSyncServer/LightlessSyncShared/Migrations/LightlessDbContextModelSnapshot.cs b/LightlessSyncServer/LightlessSyncShared/Migrations/LightlessDbContextModelSnapshot.cs index 992dc19..8be0062 100644 --- a/LightlessSyncServer/LightlessSyncShared/Migrations/LightlessDbContextModelSnapshot.cs +++ b/LightlessSyncServer/LightlessSyncShared/Migrations/LightlessDbContextModelSnapshot.cs @@ -683,6 +683,131 @@ namespace LightlessSyncServer.Migrations b.ToTable("lodestone_auth", (string)null); }); + modelBuilder.Entity("LightlessSyncShared.Models.ReportedChatMessage", b => + { + b.Property("ReportId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("report_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReportId")); + + b.Property("AdditionalContext") + .HasColumnType("text") + .HasColumnName("additional_context"); + + b.Property("ChannelKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("channel_key"); + + b.Property("ChannelType") + .HasColumnType("smallint") + .HasColumnName("channel_type"); + + b.Property("DiscordMessageId") + .HasColumnType("numeric(20,0)") + .HasColumnName("discord_message_id"); + + b.Property("DiscordMessagePostedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("discord_message_posted_at_utc"); + + b.Property("MessageContent") + .IsRequired() + .HasColumnType("text") + .HasColumnName("message_content"); + + b.Property("MessageId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("message_id"); + + b.Property("MessageSentAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("message_sent_at_utc"); + + b.Property("Reason") + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("ReportTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("report_time_utc"); + + b.Property("ReportedUserUid") + .HasColumnType("text") + .HasColumnName("reported_user_uid"); + + b.Property("ReporterUserUid") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reporter_user_uid"); + + b.Property("ResolutionNotes") + .HasColumnType("text") + .HasColumnName("resolution_notes"); + + b.Property("Resolved") + .HasColumnType("boolean") + .HasColumnName("resolved"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at_utc"); + + b.Property("ResolvedByUserUid") + .HasColumnType("text") + .HasColumnName("resolved_by_user_uid"); + + b.Property("SenderDisplayName") + .HasColumnType("text") + .HasColumnName("sender_display_name"); + + b.Property("SenderHashedCid") + .HasColumnType("text") + .HasColumnName("sender_hashed_cid"); + + b.Property("SenderToken") + .IsRequired() + .HasColumnType("text") + .HasColumnName("sender_token"); + + b.Property("SenderWasLightfinder") + .HasColumnType("boolean") + .HasColumnName("sender_was_lightfinder"); + + b.Property("SnapshotJson") + .HasColumnType("text") + .HasColumnName("snapshot_json"); + + b.Property("WorldId") + .HasColumnType("integer") + .HasColumnName("world_id"); + + b.Property("ZoneId") + .HasColumnType("integer") + .HasColumnName("zone_id"); + + b.HasKey("ReportId") + .HasName("pk_reported_chat_messages"); + + b.HasIndex("DiscordMessageId") + .HasDatabaseName("ix_reported_chat_messages_discord_message_id"); + + b.HasIndex("MessageId") + .IsUnique() + .HasDatabaseName("ix_reported_chat_messages_message_id"); + + b.HasIndex("ReportedUserUid") + .HasDatabaseName("ix_reported_chat_messages_reported_user_uid"); + + b.HasIndex("ReporterUserUid") + .HasDatabaseName("ix_reported_chat_messages_reporter_user_uid"); + + b.ToTable("reported_chat_messages", (string)null); + }); + modelBuilder.Entity("LightlessSyncShared.Models.User", b => { b.Property("UID") @@ -699,6 +824,10 @@ namespace LightlessSyncServer.Migrations .HasColumnType("boolean") .HasColumnName("has_vanity"); + b.Property("ChatBanned") + .HasColumnType("boolean") + .HasColumnName("chat_banned"); + b.Property("IsAdmin") .HasColumnType("boolean") .HasColumnName("is_admin"); diff --git a/LightlessSyncServer/LightlessSyncShared/Models/ReportedChatMessage.cs b/LightlessSyncServer/LightlessSyncShared/Models/ReportedChatMessage.cs new file mode 100644 index 0000000..f979294 --- /dev/null +++ b/LightlessSyncServer/LightlessSyncShared/Models/ReportedChatMessage.cs @@ -0,0 +1,69 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using LightlessSync.API.Dto.Chat; + +namespace LightlessSyncShared.Models; + +/// +/// Stores metadata about chat reports submitted by users. +/// +public class ReportedChatMessage +{ + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int ReportId { get; set; } + + [Required] + public DateTime ReportTimeUtc { get; set; } + + [Required] + public string ReporterUserUid { get; set; } = string.Empty; + + public string? ReportedUserUid { get; set; } + + [Required] + public ChatChannelType ChannelType { get; set; } + + public ushort WorldId { get; set; } + + public ushort ZoneId { get; set; } + + [Required] + public string ChannelKey { get; set; } = string.Empty; + + [Required] + public string MessageId { get; set; } = string.Empty; + + public DateTime MessageSentAtUtc { get; set; } + + [Required] + public string MessageContent { get; set; } = string.Empty; + + [Required] + public string SenderToken { get; set; } = string.Empty; + + public string? SenderHashedCid { get; set; } + + public string? SenderDisplayName { get; set; } + + public bool SenderWasLightfinder { get; set; } + + public string SnapshotJson { get; set; } = string.Empty; + + public string? Reason { get; set; } + + public string? AdditionalContext { get; set; } + + public ulong? DiscordMessageId { get; set; } + + public DateTime? DiscordMessagePostedAtUtc { get; set; } + + public bool Resolved { get; set; } + + public DateTime? ResolvedAtUtc { get; set; } + + public string? ResolutionNotes { get; set; } + + public string? ResolvedByUserUid { get; set; } +} diff --git a/LightlessSyncServer/LightlessSyncShared/Models/User.cs b/LightlessSyncServer/LightlessSyncShared/Models/User.cs index 6e7f612..1176581 100644 --- a/LightlessSyncServer/LightlessSyncShared/Models/User.cs +++ b/LightlessSyncServer/LightlessSyncShared/Models/User.cs @@ -22,6 +22,8 @@ public class User [MaxLength(9)] public string? TextGlowColorHex { get; set; } = string.Empty; + public bool ChatBanned { get; set; } = false; + public DateTime LastLoggedIn { get; set; } [MaxLength(15)] public string Alias { get; set; } diff --git a/LightlessSyncServer/LightlessSyncShared/Utils/Configuration/ServicesConfiguration.cs b/LightlessSyncServer/LightlessSyncShared/Utils/Configuration/ServicesConfiguration.cs index 46a173f..b461484 100644 --- a/LightlessSyncServer/LightlessSyncShared/Utils/Configuration/ServicesConfiguration.cs +++ b/LightlessSyncServer/LightlessSyncShared/Utils/Configuration/ServicesConfiguration.cs @@ -7,6 +7,7 @@ public class ServicesConfiguration : LightlessConfigurationBase public string DiscordBotToken { get; set; } = string.Empty; public ulong? DiscordChannelForMessages { get; set; } = null; public ulong? DiscordChannelForCommands { get; set; } = null; + public ulong? DiscordChannelForChatReports { get; set; } = null; public ulong? DiscordRoleAprilFools2024 { get; set; } = null; public ulong? DiscordChannelForBotLog { get; set; } = null!; public ulong? DiscordRoleRegistered { get; set; } = null!; @@ -22,6 +23,7 @@ public class ServicesConfiguration : LightlessConfigurationBase sb.AppendLine($"{nameof(MainServerAddress)} => {MainServerAddress}"); sb.AppendLine($"{nameof(DiscordChannelForMessages)} => {DiscordChannelForMessages}"); sb.AppendLine($"{nameof(DiscordChannelForCommands)} => {DiscordChannelForCommands}"); + sb.AppendLine($"{nameof(DiscordChannelForChatReports)} => {DiscordChannelForChatReports}"); sb.AppendLine($"{nameof(DiscordRoleAprilFools2024)} => {DiscordRoleAprilFools2024}"); sb.AppendLine($"{nameof(DiscordRoleRegistered)} => {DiscordRoleRegistered}"); sb.AppendLine($"{nameof(KickNonRegisteredUsers)} => {KickNonRegisteredUsers}");