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 1/5] 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}"); From 96627e3b851b349331c7c0bf6830e2c9808bd204 Mon Sep 17 00:00:00 2001 From: azyges <229218900+azyges@users.noreply.github.com> Date: Wed, 29 Oct 2025 07:55:39 +0900 Subject: [PATCH 2/5] bump submodule --- LightlessAPI | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LightlessAPI b/LightlessAPI index 67cb24a..bb88bea 160000 --- a/LightlessAPI +++ b/LightlessAPI @@ -1 +1 @@ -Subproject commit 67cb24a069bd769a38f3608e32db3f86e906823c +Subproject commit bb88bea5aade4fb3fce9d5a729ba37102e68a4d6 From 7cfe29e51115a7929cee4f59039ea0fa61a062c5 Mon Sep 17 00:00:00 2001 From: azyges <229218900+azyges@users.noreply.github.com> Date: Wed, 5 Nov 2025 01:40:48 +0900 Subject: [PATCH 3/5] clean up structs and seperate zone definitions --- .../Hubs/LightlessHub.Chat.cs | 18 +-- .../Models/BroadcastRedisEntry.cs | 2 +- .../LightlessSyncServer/Models/ChatModels.cs | 58 ++++++++++ .../Models/ChatZoneDefinitions.cs | 57 ++++++++++ .../Services/ChatChannelService.cs | 103 +----------------- 5 files changed, 126 insertions(+), 112 deletions(-) create mode 100644 LightlessSyncServer/LightlessSyncServer/Models/ChatModels.cs create mode 100644 LightlessSyncServer/LightlessSyncServer/Models/ChatZoneDefinitions.cs diff --git a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs index 0b1f198..4b9d5bd 100644 --- a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs +++ b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs @@ -5,6 +5,7 @@ using System.Text.Json; using LightlessSync.API.Data.Enum; using LightlessSync.API.Dto.Chat; using LightlessSync.API.Dto.User; +using LightlessSyncServer.Models; using LightlessSyncServer.Services; using LightlessSyncServer.Utils; using LightlessSyncShared.Models; @@ -318,8 +319,8 @@ public partial class LightlessHub return; } - var requestedChannelKey = ChatChannelService.ChannelKey.FromDescriptor(channel); - var messageChannelKey = ChatChannelService.ChannelKey.FromDescriptor(messageEntry.Channel.WithNormalizedCustomKey()); + var requestedChannelKey = ChannelKey.FromDescriptor(channel); + var messageChannelKey = 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); @@ -433,7 +434,7 @@ public partial class LightlessHub 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) + private ChatSenderDescriptor BuildSenderDescriptor(ChatChannelDescriptor descriptor, ChatParticipantInfo participant, bool includeSensitiveInfo = false) { var kind = descriptor.Type == ChatChannelType.Group ? ChatSenderKind.IdentifiedUser @@ -566,13 +567,4 @@ public partial class LightlessHub 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/Models/BroadcastRedisEntry.cs b/LightlessSyncServer/LightlessSyncServer/Models/BroadcastRedisEntry.cs index b5fe478..1d39492 100644 --- a/LightlessSyncServer/LightlessSyncServer/Models/BroadcastRedisEntry.cs +++ b/LightlessSyncServer/LightlessSyncServer/Models/BroadcastRedisEntry.cs @@ -9,4 +9,4 @@ public class BroadcastRedisEntry() public bool OwnedBy(string userUid) => !string.IsNullOrEmpty(userUid) && string.Equals(OwnerUID, userUid, StringComparison.Ordinal); public bool HasOwner() => !string.IsNullOrEmpty(OwnerUID); -} \ No newline at end of file +} diff --git a/LightlessSyncServer/LightlessSyncServer/Models/ChatModels.cs b/LightlessSyncServer/LightlessSyncServer/Models/ChatModels.cs new file mode 100644 index 0000000..c85e385 --- /dev/null +++ b/LightlessSyncServer/LightlessSyncServer/Models/ChatModels.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using LightlessSync.API.Data; +using LightlessSync.API.Dto.Chat; + +namespace LightlessSyncServer.Models; + +internal readonly record struct ChatReportSnapshotItem( + string MessageId, + DateTime SentAtUtc, + string SenderUserUid, + string? SenderAlias, + bool SenderIsLightfinder, + string? SenderHashedCid, + string Message); + +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)); + + private static string NormalizeKey(string? value) => + string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToUpperInvariant(); +} diff --git a/LightlessSyncServer/LightlessSyncServer/Models/ChatZoneDefinitions.cs b/LightlessSyncServer/LightlessSyncServer/Models/ChatZoneDefinitions.cs new file mode 100644 index 0000000..ebef986 --- /dev/null +++ b/LightlessSyncServer/LightlessSyncServer/Models/ChatZoneDefinitions.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using LightlessSync.API.Dto.Chat; + +namespace LightlessSyncServer.Models; + +internal static class ChatZoneDefinitions +{ + public static IReadOnlyList Defaults { get; } = + new[] + { + 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" + }), + new ZoneChannelDefinition( + Key: "gridania", + DisplayName: "Gridania", + Descriptor: new ChatChannelDescriptor + { + Type = ChatChannelType.Zone, + WorldId = 0, + ZoneId = 0, + CustomKey = "gridania" + }, + TerritoryNames: new[] + { + "New Gridania", + "Old Gridania" + }), + 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" + }) + }; +} diff --git a/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs b/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs index 30a24b5..0a411d1 100644 --- a/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs +++ b/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs @@ -1,9 +1,10 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Security.Cryptography; using LightlessSync.API.Data; using LightlessSync.API.Dto.Chat; +using LightlessSyncServer.Models; using Microsoft.Extensions.Logging; namespace LightlessSyncServer.Services; @@ -11,7 +12,7 @@ namespace LightlessSyncServer.Services; public sealed class ChatChannelService { private readonly ILogger _logger; - private readonly Dictionary _zoneDefinitions = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _zoneDefinitions; private readonly Dictionary> _membersByChannel = new(); private readonly Dictionary> _presenceByUser = new(StringComparer.Ordinal); private readonly Dictionary> _participantsByChannel = new(); @@ -23,54 +24,8 @@ public sealed class ChatChannelService 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" - })); + _zoneDefinitions = ChatZoneDefinitions.Defaults + .ToDictionary(definition => definition.Key, StringComparer.OrdinalIgnoreCase); } public IReadOnlyList GetZoneChannelInfos() => @@ -454,11 +409,6 @@ public sealed class ChatChannelService return true; } - private void AddZoneDefinition(ZoneChannelDefinition definition) - { - _zoneDefinitions[definition.Key] = definition; - } - private static string GenerateToken() { Span buffer = stackalloc byte[8]; @@ -468,47 +418,4 @@ public sealed class ChatChannelService 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)); - } } From cf5135f598ab46eec4e4944ccffab31529754cb8 Mon Sep 17 00:00:00 2001 From: azyges <229218900+azyges@users.noreply.github.com> Date: Sat, 8 Nov 2025 07:38:35 +0900 Subject: [PATCH 4/5] add generated world, territory registries and serverside verification for only legit territories and worlds defined by server --- .../Hubs/LightlessHub.Chat.cs | 11 + .../LightlessSyncServer/Models/ChatModels.cs | 3 +- .../Models/ChatZoneDefinitions.cs | 16 +- .../Models/TerritoryDefinition.cs | 5 + .../Models/TerritoryRegistry.generated.cs | 1121 +++++++++++++++++ .../Models/WorldDefinition.cs | 7 + .../Models/WorldRegistry.generated.cs | 117 ++ .../Services/ChatChannelService.cs | 15 +- 8 files changed, 1290 insertions(+), 5 deletions(-) create mode 100644 LightlessSyncServer/LightlessSyncServer/Models/TerritoryDefinition.cs create mode 100644 LightlessSyncServer/LightlessSyncServer/Models/TerritoryRegistry.generated.cs create mode 100644 LightlessSyncServer/LightlessSyncServer/Models/WorldDefinition.cs create mode 100644 LightlessSyncServer/LightlessSyncServer/Models/WorldRegistry.generated.cs diff --git a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs index 4b9d5bd..4ae561a 100644 --- a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs +++ b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs @@ -94,6 +94,16 @@ public partial class LightlessHub throw new HubException("Unsupported chat channel."); } + if (channel.WorldId == 0 || !WorldRegistry.IsKnownWorld(channel.WorldId)) + { + throw new HubException("Unsupported chat channel."); + } + + if (presence.TerritoryId == 0 || !definition.TerritoryIds.Contains(presence.TerritoryId)) + { + throw new HubException("Zone chat is only available in supported territories."); + } + string? hashedCid = null; var isLightfinder = false; if (IsValidHashedCid(UserCharaIdent)) @@ -110,6 +120,7 @@ public partial class LightlessHub UserUID, definition, channel.WorldId, + presence.TerritoryId, hashedCid, isLightfinder, isActive: true); diff --git a/LightlessSyncServer/LightlessSyncServer/Models/ChatModels.cs b/LightlessSyncServer/LightlessSyncServer/Models/ChatModels.cs index c85e385..39214dc 100644 --- a/LightlessSyncServer/LightlessSyncServer/Models/ChatModels.cs +++ b/LightlessSyncServer/LightlessSyncServer/Models/ChatModels.cs @@ -43,7 +43,8 @@ public readonly record struct ZoneChannelDefinition( string Key, string DisplayName, ChatChannelDescriptor Descriptor, - IReadOnlyList TerritoryNames); + IReadOnlyList TerritoryNames, + IReadOnlySet TerritoryIds); public readonly record struct ChannelKey(ChatChannelType Type, ushort WorldId, string CustomKey) { diff --git a/LightlessSyncServer/LightlessSyncServer/Models/ChatZoneDefinitions.cs b/LightlessSyncServer/LightlessSyncServer/Models/ChatZoneDefinitions.cs index ebef986..ddc12f5 100644 --- a/LightlessSyncServer/LightlessSyncServer/Models/ChatZoneDefinitions.cs +++ b/LightlessSyncServer/LightlessSyncServer/Models/ChatZoneDefinitions.cs @@ -22,7 +22,10 @@ internal static class ChatZoneDefinitions { "Limsa Lominsa Lower Decks", "Limsa Lominsa Upper Decks" - }), + }, + TerritoryIds: TerritoryRegistry.GetIds( + "Limsa Lominsa Lower Decks", + "Limsa Lominsa Upper Decks")), new ZoneChannelDefinition( Key: "gridania", DisplayName: "Gridania", @@ -37,7 +40,10 @@ internal static class ChatZoneDefinitions { "New Gridania", "Old Gridania" - }), + }, + TerritoryIds: TerritoryRegistry.GetIds( + "New Gridania", + "Old Gridania")), new ZoneChannelDefinition( Key: "uldah", DisplayName: "Ul'dah", @@ -52,6 +58,10 @@ internal static class ChatZoneDefinitions { "Ul'dah - Steps of Nald", "Ul'dah - Steps of Thal" - }) + }, + TerritoryIds: TerritoryRegistry.GetIds( + "Ul'dah - Steps of Nald", + "Ul'dah - Steps of Thal")) }; + } diff --git a/LightlessSyncServer/LightlessSyncServer/Models/TerritoryDefinition.cs b/LightlessSyncServer/LightlessSyncServer/Models/TerritoryDefinition.cs new file mode 100644 index 0000000..cdba485 --- /dev/null +++ b/LightlessSyncServer/LightlessSyncServer/Models/TerritoryDefinition.cs @@ -0,0 +1,5 @@ +namespace LightlessSyncServer.Models; + +internal readonly record struct TerritoryDefinition( + ushort TerritoryId, + string Name); diff --git a/LightlessSyncServer/LightlessSyncServer/Models/TerritoryRegistry.generated.cs b/LightlessSyncServer/LightlessSyncServer/Models/TerritoryRegistry.generated.cs new file mode 100644 index 0000000..c12844c --- /dev/null +++ b/LightlessSyncServer/LightlessSyncServer/Models/TerritoryRegistry.generated.cs @@ -0,0 +1,1121 @@ +// +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +namespace LightlessSyncServer.Models; + +internal static class TerritoryRegistry +{ + private static readonly TerritoryDefinition[] TerritoryArray = new[] + { + new TerritoryDefinition(1238, "A Future Rewritten"), + new TerritoryDefinition(560, "Aetherochemical Research Facility"), + new TerritoryDefinition(702, "Aetherochemical Research Facility"), + new TerritoryDefinition(1110, "Aetherochemical Research Facility"), + new TerritoryDefinition(1054, "Aglaia"), + new TerritoryDefinition(841, "Akadaemia Anyder"), + new TerritoryDefinition(377, "Akh Afah Amphitheatre"), + new TerritoryDefinition(378, "Akh Afah Amphitheatre"), + new TerritoryDefinition(457, "Akh Afah Amphitheatre"), + new TerritoryDefinition(403, "Ala Mhigo"), + new TerritoryDefinition(1146, "Ala Mhigo"), + new TerritoryDefinition(1217, "Ala Mhigo"), + new TerritoryDefinition(505, "Alexander"), + new TerritoryDefinition(553, "Alexander"), + new TerritoryDefinition(1199, "Alexandria"), + new TerritoryDefinition(1176, "Aloalo Island"), + new TerritoryDefinition(1050, "Alzadaal's Legacy"), + new TerritoryDefinition(1056, "Alzadaal's Legacy"), + new TerritoryDefinition(838, "Amaurot"), + new TerritoryDefinition(167, "Amdapor Keep"), + new TerritoryDefinition(189, "Amdapor Keep"), + new TerritoryDefinition(396, "Amdapor Keep"), + new TerritoryDefinition(721, "Amdapor Keep"), + new TerritoryDefinition(815, "Amh Araeng"), + new TerritoryDefinition(860, "Amh Araeng"), + new TerritoryDefinition(872, "Amh Araeng"), + new TerritoryDefinition(898, "Anamnesis Anyder"), + new TerritoryDefinition(918, "Anamnesis Anyder"), + new TerritoryDefinition(990, "Andron"), + new TerritoryDefinition(1179, "Another Aloalo Island"), + new TerritoryDefinition(1180, "Another Aloalo Island"), + new TerritoryDefinition(1155, "Another Mount Rokkon"), + new TerritoryDefinition(1156, "Another Mount Rokkon"), + new TerritoryDefinition(1075, "Another Sil'dihn Subterrane"), + new TerritoryDefinition(1076, "Another Sil'dihn Subterrane"), + new TerritoryDefinition(1153, "Ascension"), + new TerritoryDefinition(1154, "Ascension"), + new TerritoryDefinition(903, "Ashfall"), + new TerritoryDefinition(907, "Ashfall"), + new TerritoryDefinition(729, "Astragalos"), + new TerritoryDefinition(852, "Atlas Peak"), + new TerritoryDefinition(856, "Atlas Peak"), + new TerritoryDefinition(172, "Aurum Vale"), + new TerritoryDefinition(402, "Azys Lla"), + new TerritoryDefinition(459, "Azys Lla"), + new TerritoryDefinition(1114, "Baelsar's Wall"), + new TerritoryDefinition(714, "Bardam's Mettle"), + new TerritoryDefinition(1143, "Bardam's Mettle"), + new TerritoryDefinition(1216, "Bardam's Mettle"), + new TerritoryDefinition(1013, "Beyond the Stars"), + new TerritoryDefinition(1026, "Beyond the Stars"), + new TerritoryDefinition(1229, "Blasting Ring"), + new TerritoryDefinition(1230, "Blasting Ring"), + new TerritoryDefinition(796, "Blue Sky"), + new TerritoryDefinition(1165, "Blunderville"), + new TerritoryDefinition(1197, "Blunderville Square"), + new TerritoryDefinition(629, "Bokairo Inn"), + new TerritoryDefinition(292, "Bowl of Embers"), + new TerritoryDefinition(295, "Bowl of Embers"), + new TerritoryDefinition(592, "Bowl of Embers"), + new TerritoryDefinition(1045, "Bowl of Embers"), + new TerritoryDefinition(920, "Bozjan Southern Front"), + new TerritoryDefinition(362, "Brayflox's Longstop"), + new TerritoryDefinition(1041, "Brayflox's Longstop"), + new TerritoryDefinition(1215, "Brayflox's Longstop"), + new TerritoryDefinition(1268, "Break Room"), + new TerritoryDefinition(581, "Breath of the Creator"), + new TerritoryDefinition(585, "Breath of the Creator"), + new TerritoryDefinition(944, "Bygone Gaol"), + new TerritoryDefinition(948, "Bygone Gaol"), + new TerritoryDefinition(376, "Carteneau Flats: Borderland Ruins"), + new TerritoryDefinition(502, "Carteneau Flats: Borderland Ruins"), + new TerritoryDefinition(633, "Carteneau Flats: Borderland Ruins"), + new TerritoryDefinition(977, "Carteneau Flats: Borderland Ruins"), + new TerritoryDefinition(1273, "Carteneau Flats: Borderland Ruins"), + new TerritoryDefinition(1145, "Castrum Abania"), + new TerritoryDefinition(778, "Castrum Fluminis"), + new TerritoryDefinition(779, "Castrum Fluminis"), + new TerritoryDefinition(786, "Castrum Fluminis"), + new TerritoryDefinition(934, "Castrum Marinum Drydocks"), + new TerritoryDefinition(935, "Castrum Marinum Drydocks"), + new TerritoryDefinition(967, "Castrum Marinum Drydocks"), + new TerritoryDefinition(1043, "Castrum Meridianum"), + new TerritoryDefinition(1209, "Cenote Ja Ja Gural"), + new TerritoryDefinition(507, "Central Azys Lla"), + new TerritoryDefinition(357, "Central Decks"), + new TerritoryDefinition(382, "Central Decks"), + new TerritoryDefinition(148, "Central Shroud"), + new TerritoryDefinition(190, "Central Shroud"), + new TerritoryDefinition(219, "Central Shroud"), + new TerritoryDefinition(225, "Central Shroud"), + new TerritoryDefinition(226, "Central Shroud"), + new TerritoryDefinition(227, "Central Shroud"), + new TerritoryDefinition(230, "Central Shroud"), + new TerritoryDefinition(233, "Central Shroud"), + new TerritoryDefinition(237, "Central Shroud"), + new TerritoryDefinition(239, "Central Shroud"), + new TerritoryDefinition(319, "Central Shroud"), + new TerritoryDefinition(320, "Central Shroud"), + new TerritoryDefinition(1015, "Central Shroud"), + new TerritoryDefinition(141, "Central Thanalan"), + new TerritoryDefinition(216, "Central Thanalan"), + new TerritoryDefinition(248, "Central Thanalan"), + new TerritoryDefinition(253, "Central Thanalan"), + new TerritoryDefinition(258, "Central Thanalan"), + new TerritoryDefinition(270, "Central Thanalan"), + new TerritoryDefinition(271, "Central Thanalan"), + new TerritoryDefinition(314, "Central Thanalan"), + new TerritoryDefinition(1235, "Central Thanalan"), + new TerritoryDefinition(388, "Chocobo Square"), + new TerritoryDefinition(389, "Chocobo Square"), + new TerritoryDefinition(390, "Chocobo Square"), + new TerritoryDefinition(391, "Chocobo Square"), + new TerritoryDefinition(417, "Chocobo Square"), + new TerritoryDefinition(506, "Chocobo Square"), + new TerritoryDefinition(589, "Chocobo Square"), + new TerritoryDefinition(590, "Chocobo Square"), + new TerritoryDefinition(591, "Chocobo Square"), + new TerritoryDefinition(911, "Cid's Memory"), + new TerritoryDefinition(897, "Cinder Drift"), + new TerritoryDefinition(912, "Cinder Drift"), + new TerritoryDefinition(429, "Cloud Nine"), + new TerritoryDefinition(1034, "Cloud Nine"), + new TerritoryDefinition(1060, "Cloud Nine"), + new TerritoryDefinition(155, "Coerthas Central Highlands"), + new TerritoryDefinition(223, "Coerthas Central Highlands"), + new TerritoryDefinition(298, "Coerthas Central Highlands"), + new TerritoryDefinition(301, "Coerthas Central Highlands"), + new TerritoryDefinition(302, "Coerthas Central Highlands"), + new TerritoryDefinition(304, "Coerthas Central Highlands"), + new TerritoryDefinition(313, "Coerthas Central Highlands"), + new TerritoryDefinition(316, "Coerthas Central Highlands"), + new TerritoryDefinition(322, "Coerthas Central Highlands"), + new TerritoryDefinition(468, "Coerthas Central Highlands"), + new TerritoryDefinition(469, "Coerthas Central Highlands"), + new TerritoryDefinition(475, "Coerthas Central Highlands"), + new TerritoryDefinition(487, "Coerthas Central Highlands"), + new TerritoryDefinition(488, "Coerthas Central Highlands"), + new TerritoryDefinition(496, "Coerthas Central Highlands"), + new TerritoryDefinition(500, "Coerthas Central Highlands"), + new TerritoryDefinition(533, "Coerthas Central Highlands"), + new TerritoryDefinition(699, "Coerthas Central Highlands"), + new TerritoryDefinition(397, "Coerthas Western Highlands"), + new TerritoryDefinition(467, "Coerthas Western Highlands"), + new TerritoryDefinition(470, "Coerthas Western Highlands"), + new TerritoryDefinition(472, "Coerthas Western Highlands"), + new TerritoryDefinition(477, "Coerthas Western Highlands"), + new TerritoryDefinition(479, "Coerthas Western Highlands"), + new TerritoryDefinition(489, "Coerthas Western Highlands"), + new TerritoryDefinition(493, "Coerthas Western Highlands"), + new TerritoryDefinition(497, "Coerthas Western Highlands"), + new TerritoryDefinition(498, "Coerthas Western Highlands"), + new TerritoryDefinition(709, "Coerthas Western Highlands"), + new TerritoryDefinition(866, "Coerthas Western Highlands"), + new TerritoryDefinition(198, "Command Room"), + new TerritoryDefinition(984, "Company Workshop - Empyreum"), + new TerritoryDefinition(423, "Company Workshop - Mist"), + new TerritoryDefinition(653, "Company Workshop - Shirogane"), + new TerritoryDefinition(424, "Company Workshop - The Goblet"), + new TerritoryDefinition(425, "Company Workshop - The Lavender Beds"), + new TerritoryDefinition(576, "Containment Bay P1T6"), + new TerritoryDefinition(577, "Containment Bay P1T6"), + new TerritoryDefinition(517, "Containment Bay S1T7"), + new TerritoryDefinition(524, "Containment Bay S1T7"), + new TerritoryDefinition(637, "Containment Bay Z1T9"), + new TerritoryDefinition(638, "Containment Bay Z1T9"), + new TerritoryDefinition(1299, "Containment Complex 10-29"), + new TerritoryDefinition(349, "Copperbell Mines"), + new TerritoryDefinition(1038, "Copperbell Mines"), + new TerritoryDefinition(1020, "Cutter's Cry"), + new TerritoryDefinition(1303, "Cutter's Cry"), + new TerritoryDefinition(355, "Dalamud's Shadow"), + new TerritoryDefinition(380, "Dalamud's Shadow"), + new TerritoryDefinition(704, "Dalamud's Shadow"), + new TerritoryDefinition(691, "Deltascape V1.0"), + new TerritoryDefinition(695, "Deltascape V1.0"), + new TerritoryDefinition(692, "Deltascape V2.0"), + new TerritoryDefinition(696, "Deltascape V2.0"), + new TerritoryDefinition(693, "Deltascape V3.0"), + new TerritoryDefinition(697, "Deltascape V3.0"), + new TerritoryDefinition(694, "Deltascape V4.0"), + new TerritoryDefinition(698, "Deltascape V4.0"), + new TerritoryDefinition(936, "Delubrum Reginae"), + new TerritoryDefinition(937, "Delubrum Reginae"), + new TerritoryDefinition(1260, "Demolition Site"), + new TerritoryDefinition(1261, "Demolition Site"), + new TerritoryDefinition(1276, "Demolition Site"), + new TerritoryDefinition(821, "Dohn Mheg"), + new TerritoryDefinition(1144, "Doma Castle"), + new TerritoryDefinition(1234, "Dreamlike Palace"), + new TerritoryDefinition(627, "Dun Scaith"), + new TerritoryDefinition(434, "Dusk Vigil"), + new TerritoryDefinition(1021, "Dusk Vigil"), + new TerritoryDefinition(171, "Dzemael Darkhold"), + new TerritoryDefinition(1171, "Earthen Sky Hideout"), + new TerritoryDefinition(152, "East Shroud"), + new TerritoryDefinition(191, "East Shroud"), + new TerritoryDefinition(234, "East Shroud"), + new TerritoryDefinition(277, "East Shroud"), + new TerritoryDefinition(289, "East Shroud"), + new TerritoryDefinition(290, "East Shroud"), + new TerritoryDefinition(303, "East Shroud"), + new TerritoryDefinition(839, "East Shroud"), + new TerritoryDefinition(137, "Eastern La Noscea"), + new TerritoryDefinition(310, "Eastern La Noscea"), + new TerritoryDefinition(311, "Eastern La Noscea"), + new TerritoryDefinition(327, "Eastern La Noscea"), + new TerritoryDefinition(408, "Eastern La Noscea"), + new TerritoryDefinition(411, "Eastern La Noscea"), + new TerritoryDefinition(414, "Eastern La Noscea"), + new TerritoryDefinition(471, "Eastern La Noscea"), + new TerritoryDefinition(867, "Eastern La Noscea"), + new TerritoryDefinition(145, "Eastern Thanalan"), + new TerritoryDefinition(256, "Eastern Thanalan"), + new TerritoryDefinition(257, "Eastern Thanalan"), + new TerritoryDefinition(266, "Eastern Thanalan"), + new TerritoryDefinition(268, "Eastern Thanalan"), + new TerritoryDefinition(275, "Eastern Thanalan"), + new TerritoryDefinition(465, "Eastern Thanalan"), + new TerritoryDefinition(494, "Eastern Thanalan"), + new TerritoryDefinition(668, "Eastern Thanalan"), + new TerritoryDefinition(961, "Elpis"), + new TerritoryDefinition(1014, "Elpis"), + new TerritoryDefinition(1073, "Elysion"), + new TerritoryDefinition(719, "Emanation"), + new TerritoryDefinition(720, "Emanation"), + new TerritoryDefinition(979, "Empyreum"), + new TerritoryDefinition(829, "Eorzean Alliance Headquarters"), + new TerritoryDefinition(338, "Eorzean Subterrane"), + new TerritoryDefinition(1161, "Estinien's Chambers"), + new TerritoryDefinition(820, "Eulmore"), + new TerritoryDefinition(863, "Eulmore"), + new TerritoryDefinition(1118, "Euphrosyne"), + new TerritoryDefinition(732, "Eureka Anemos"), + new TerritoryDefinition(827, "Eureka Hydatos"), + new TerritoryDefinition(1099, "Eureka Orthos"), + new TerritoryDefinition(1100, "Eureka Orthos"), + new TerritoryDefinition(1101, "Eureka Orthos"), + new TerritoryDefinition(1102, "Eureka Orthos"), + new TerritoryDefinition(1103, "Eureka Orthos"), + new TerritoryDefinition(1104, "Eureka Orthos"), + new TerritoryDefinition(1105, "Eureka Orthos"), + new TerritoryDefinition(1106, "Eureka Orthos"), + new TerritoryDefinition(1107, "Eureka Orthos"), + new TerritoryDefinition(1108, "Eureka Orthos"), + new TerritoryDefinition(1124, "Eureka Orthos"), + new TerritoryDefinition(763, "Eureka Pagos"), + new TerritoryDefinition(795, "Eureka Pyros"), + new TerritoryDefinition(895, "Excavation Tunnels"), + new TerritoryDefinition(580, "Eyes of the Creator"), + new TerritoryDefinition(584, "Eyes of the Creator"), + new TerritoryDefinition(535, "Flame Barracks"), + new TerritoryDefinition(433, "Fortemps Manor"), + new TerritoryDefinition(418, "Foundation"), + new TerritoryDefinition(458, "Foundation"), + new TerritoryDefinition(700, "Foundation"), + new TerritoryDefinition(611, "Frondale's Home for Friendless Foundlings"), + new TerritoryDefinition(921, "Frondale's Home for Friendless Foundlings"), + new TerritoryDefinition(950, "G-Savior Deck"), + new TerritoryDefinition(951, "G-Savior Deck"), + new TerritoryDefinition(991, "G-Savior Deck"), + new TerritoryDefinition(915, "Gangos"), + new TerritoryDefinition(958, "Garlemald"), + new TerritoryDefinition(1011, "Garlemald"), + new TerritoryDefinition(1120, "Garlemald"), + new TerritoryDefinition(905, "Great Glacier"), + new TerritoryDefinition(909, "Great Glacier"), + new TerritoryDefinition(1224, "Greenroom"), + new TerritoryDefinition(366, "Griffin Crossing"), + new TerritoryDefinition(1256, "Groovy Ring"), + new TerritoryDefinition(1257, "Groovy Ring"), + new TerritoryDefinition(142, "Halatali"), + new TerritoryDefinition(360, "Halatali"), + new TerritoryDefinition(460, "Halatali"), + new TerritoryDefinition(1245, "Halatali"), + new TerritoryDefinition(276, "Hall of Summoning"), + new TerritoryDefinition(369, "Hall of the Bestiarii"), + new TerritoryDefinition(1255, "Hall of the Unbound"), + new TerritoryDefinition(350, "Haukke Manor"), + new TerritoryDefinition(1040, "Haukke Manor"), + new TerritoryDefinition(571, "Haunted Manor"), + new TerritoryDefinition(809, "Haunted Manor"), + new TerritoryDefinition(1305, "Haunted Manor"), + new TerritoryDefinition(582, "Heart of the Creator"), + new TerritoryDefinition(586, "Heart of the Creator"), + new TerritoryDefinition(588, "Heart of the Creator"), + new TerritoryDefinition(210, "Heart of the Sworn"), + new TerritoryDefinition(770, "Heaven-on-High"), + new TerritoryDefinition(771, "Heaven-on-High"), + new TerritoryDefinition(772, "Heaven-on-High"), + new TerritoryDefinition(773, "Heaven-on-High"), + new TerritoryDefinition(774, "Heaven-on-High"), + new TerritoryDefinition(775, "Heaven-on-High"), + new TerritoryDefinition(780, "Heaven-on-High"), + new TerritoryDefinition(782, "Heaven-on-High"), + new TerritoryDefinition(783, "Heaven-on-High"), + new TerritoryDefinition(784, "Heaven-on-High"), + new TerritoryDefinition(785, "Heaven-on-High"), + new TerritoryDefinition(810, "Hells' Kier"), + new TerritoryDefinition(811, "Hells' Kier"), + new TerritoryDefinition(742, "Hells' Lid"), + new TerritoryDefinition(1191, "Heritage Found"), + new TerritoryDefinition(791, "Hidden Gorge"), + new TerritoryDefinition(837, "Holminster Switch"), + new TerritoryDefinition(361, "Hullbreaker Isle"), + new TerritoryDefinition(490, "Hullbreaker Isle"), + new TerritoryDefinition(557, "Hullbreaker Isle"), + new TerritoryDefinition(1262, "Hunter's Ring"), + new TerritoryDefinition(1263, "Hunter's Ring"), + new TerritoryDefinition(246, "IC-04 Main Bridge"), + new TerritoryDefinition(193, "IC-06 Central Decks"), + new TerritoryDefinition(195, "IC-06 Main Bridge"), + new TerritoryDefinition(194, "IC-06 Regeneration Grid"), + new TerritoryDefinition(478, "Idyllshire"), + new TerritoryDefinition(1167, "Ihuykatumu"), + new TerritoryDefinition(816, "Il Mheg"), + new TerritoryDefinition(869, "Il Mheg"), + new TerritoryDefinition(999, "Ingleside Apartment"), + new TerritoryDefinition(985, "Ingleside Apartment Lobby"), + new TerritoryDefinition(395, "Intercessory"), + new TerritoryDefinition(1202, "Interphos"), + new TerritoryDefinition(1221, "Interphos"), + new TerritoryDefinition(1243, "Interphos"), + new TerritoryDefinition(1248, "Jeuno: The First Walk"), + new TerritoryDefinition(1125, "Khadga"), + new TerritoryDefinition(1218, "Khadga"), + new TerritoryDefinition(814, "Kholusia"), + new TerritoryDefinition(864, "Kholusia"), + new TerritoryDefinition(870, "Kholusia"), + new TerritoryDefinition(744, "Kienkan"), + new TerritoryDefinition(655, "Kobai Goten Apartment"), + new TerritoryDefinition(654, "Kobai Goten Apartment Lobby"), + new TerritoryDefinition(1188, "Kozama'uka"), + new TerritoryDefinition(974, "Ktisis Hyperboreia"), + new TerritoryDefinition(628, "Kugane"), + new TerritoryDefinition(664, "Kugane"), + new TerritoryDefinition(665, "Kugane"), + new TerritoryDefinition(667, "Kugane"), + new TerritoryDefinition(710, "Kugane"), + new TerritoryDefinition(662, "Kugane Castle"), + new TerritoryDefinition(353, "Kugane Ohashi"), + new TerritoryDefinition(806, "Kugane Ohashi"), + new TerritoryDefinition(174, "Labyrinth of the Ancients"), + new TerritoryDefinition(956, "Labyrinthos"), + new TerritoryDefinition(813, "Lakeland"), + new TerritoryDefinition(861, "Lakeland"), + new TerritoryDefinition(862, "Lakeland"), + new TerritoryDefinition(877, "Lakeland"), + new TerritoryDefinition(1097, "Lapis Manalis"), + new TerritoryDefinition(1119, "Lapis Manalis"), + new TerritoryDefinition(943, "Laxan Loft"), + new TerritoryDefinition(947, "Laxan Loft"), + new TerritoryDefinition(971, "Lemures Headquarters"), + new TerritoryDefinition(568, "Leofard's Chambers"), + new TerritoryDefinition(609, "Lily Hills Apartment"), + new TerritoryDefinition(574, "Lily Hills Apartment Lobby"), + new TerritoryDefinition(887, "Liminal Space"), + new TerritoryDefinition(181, "Limsa Lominsa"), + new TerritoryDefinition(129, "Limsa Lominsa Lower Decks"), + new TerritoryDefinition(404, "Limsa Lominsa Lower Decks"), + new TerritoryDefinition(128, "Limsa Lominsa Upper Decks"), + new TerritoryDefinition(409, "Limsa Lominsa Upper Decks"), + new TerritoryDefinition(474, "Limsa Lominsa Upper Decks"), + new TerritoryDefinition(1192, "Living Memory"), + new TerritoryDefinition(205, "Lotus Stand"), + new TerritoryDefinition(1227, "Lovely Lovering"), + new TerritoryDefinition(1228, "Lovely Lovering"), + new TerritoryDefinition(242, "Lower Aetheroacoustic Exploratory Site"), + new TerritoryDefinition(1264, "Lower Jeuno"), + new TerritoryDefinition(1265, "Lower Jeuno"), + new TerritoryDefinition(135, "Lower La Noscea"), + new TerritoryDefinition(222, "Lower La Noscea"), + new TerritoryDefinition(249, "Lower La Noscea"), + new TerritoryDefinition(262, "Lower La Noscea"), + new TerritoryDefinition(264, "Lower La Noscea"), + new TerritoryDefinition(265, "Lower La Noscea"), + new TerritoryDefinition(279, "Lower La Noscea"), + new TerritoryDefinition(287, "Lower La Noscea"), + new TerritoryDefinition(307, "Lower La Noscea"), + new TerritoryDefinition(415, "Lower La Noscea"), + new TerritoryDefinition(484, "Lower La Noscea"), + new TerritoryDefinition(495, "Lower La Noscea"), + new TerritoryDefinition(889, "Lyhe Mheg"), + new TerritoryDefinition(890, "Lyhe Mheg"), + new TerritoryDefinition(891, "Lyhe Mheg"), + new TerritoryDefinition(892, "Lyhe Mheg"), + new TerritoryDefinition(894, "Lyhe Mheg"), + new TerritoryDefinition(536, "Maelstrom Barracks"), + new TerritoryDefinition(1010, "Magna Glacies"), + new TerritoryDefinition(1012, "Magna Glacies"), + new TerritoryDefinition(370, "Main Bridge"), + new TerritoryDefinition(1206, "Main Deck"), + new TerritoryDefinition(1277, "Main Deck"), + new TerritoryDefinition(987, "Main Hall"), + new TerritoryDefinition(836, "Malikah's Well"), + new TerritoryDefinition(1233, "Manor Basement"), + new TerritoryDefinition(959, "Mare Lamentorum"), + new TerritoryDefinition(463, "Matoya's Cave"), + new TerritoryDefinition(933, "Matoya's Relict"), + new TerritoryDefinition(968, "Medias Res"), + new TerritoryDefinition(1078, "Meghaduta Guest Chambers"), + new TerritoryDefinition(134, "Middle La Noscea"), + new TerritoryDefinition(214, "Middle La Noscea"), + new TerritoryDefinition(252, "Middle La Noscea"), + new TerritoryDefinition(272, "Middle La Noscea"), + new TerritoryDefinition(285, "Middle La Noscea"), + new TerritoryDefinition(136, "Mist"), + new TerritoryDefinition(339, "Mist"), + new TerritoryDefinition(177, "Mizzenmast Inn"), + new TerritoryDefinition(156, "Mor Dhona"), + new TerritoryDefinition(299, "Mor Dhona"), + new TerritoryDefinition(300, "Mor Dhona"), + new TerritoryDefinition(305, "Mor Dhona"), + new TerritoryDefinition(308, "Mor Dhona"), + new TerritoryDefinition(309, "Mor Dhona"), + new TerritoryDefinition(315, "Mor Dhona"), + new TerritoryDefinition(326, "Mor Dhona"), + new TerritoryDefinition(335, "Mor Dhona"), + new TerritoryDefinition(379, "Mor Dhona"), + new TerritoryDefinition(480, "Mor Dhona"), + new TerritoryDefinition(672, "Mor Dhona"), + new TerritoryDefinition(176, "Mordion Gaol"), + new TerritoryDefinition(728, "Mordion Gaol"), + new TerritoryDefinition(1095, "Mount Ordeals"), + new TerritoryDefinition(1096, "Mount Ordeals"), + new TerritoryDefinition(1137, "Mount Rokkon"), + new TerritoryDefinition(822, "Mt. Gulg"), + new TerritoryDefinition(420, "Neverreap"), + new TerritoryDefinition(132, "New Gridania"), + new TerritoryDefinition(183, "New Gridania"), + new TerritoryDefinition(154, "North Shroud"), + new TerritoryDefinition(228, "North Shroud"), + new TerritoryDefinition(240, "North Shroud"), + new TerritoryDefinition(321, "North Shroud"), + new TerritoryDefinition(324, "North Shroud"), + new TerritoryDefinition(147, "Northern Thanalan"), + new TerritoryDefinition(410, "Northern Thanalan"), + new TerritoryDefinition(483, "Northern Thanalan"), + new TerritoryDefinition(133, "Old Gridania"), + new TerritoryDefinition(238, "Old Gridania"), + new TerritoryDefinition(865, "Old Gridania"), + new TerritoryDefinition(962, "Old Sharlayan"), + new TerritoryDefinition(636, "Omega Control"), + new TerritoryDefinition(888, "Onsal Hakair"), + new TerritoryDefinition(1208, "Origenics"), + new TerritoryDefinition(180, "Outer La Noscea"), + new TerritoryDefinition(325, "Outer La Noscea"), + new TerritoryDefinition(486, "Outer La Noscea"), + new TerritoryDefinition(938, "Paglth'an"), + new TerritoryDefinition(1291, "Phaenna"), + new TerritoryDefinition(1269, "Phantom Village"), + new TerritoryDefinition(1278, "Phantom Village"), + new TerritoryDefinition(160, "Pharos Sirius"), + new TerritoryDefinition(510, "Pharos Sirius"), + new TerritoryDefinition(1280, "Pilgrim's Traverse"), + new TerritoryDefinition(1281, "Pilgrim's Traverse"), + new TerritoryDefinition(1282, "Pilgrim's Traverse"), + new TerritoryDefinition(1283, "Pilgrim's Traverse"), + new TerritoryDefinition(1284, "Pilgrim's Traverse"), + new TerritoryDefinition(1285, "Pilgrim's Traverse"), + new TerritoryDefinition(1286, "Pilgrim's Traverse"), + new TerritoryDefinition(1287, "Pilgrim's Traverse"), + new TerritoryDefinition(1288, "Pilgrim's Traverse"), + new TerritoryDefinition(1289, "Pilgrim's Traverse"), + new TerritoryDefinition(1290, "Pilgrim's Traverse"), + new TerritoryDefinition(1311, "Pilgrim's Traverse"), + new TerritoryDefinition(1333, "Pilgrim's Traverse"), + new TerritoryDefinition(348, "Porta Decumana"), + new TerritoryDefinition(1048, "Porta Decumana"), + new TerritoryDefinition(983, "Private Chambers - Empyreum"), + new TerritoryDefinition(384, "Private Chambers - Mist"), + new TerritoryDefinition(652, "Private Chambers - Shirogane"), + new TerritoryDefinition(386, "Private Chambers - The Goblet"), + new TerritoryDefinition(385, "Private Chambers - The Lavender Beds"), + new TerritoryDefinition(980, "Private Cottage - Empyreum"), + new TerritoryDefinition(282, "Private Cottage - Mist"), + new TerritoryDefinition(649, "Private Cottage - Shirogane"), + new TerritoryDefinition(345, "Private Cottage - The Goblet"), + new TerritoryDefinition(342, "Private Cottage - The Lavender Beds"), + new TerritoryDefinition(981, "Private House - Empyreum"), + new TerritoryDefinition(283, "Private House - Mist"), + new TerritoryDefinition(650, "Private House - Shirogane"), + new TerritoryDefinition(346, "Private House - The Goblet"), + new TerritoryDefinition(343, "Private House - The Lavender Beds"), + new TerritoryDefinition(982, "Private Mansion - Empyreum"), + new TerritoryDefinition(284, "Private Mansion - Mist"), + new TerritoryDefinition(651, "Private Mansion - Shirogane"), + new TerritoryDefinition(347, "Private Mansion - The Goblet"), + new TerritoryDefinition(344, "Private Mansion - The Lavender Beds"), + new TerritoryDefinition(1031, "Propylaion"), + new TerritoryDefinition(798, "Psiscape V1.0"), + new TerritoryDefinition(802, "Psiscape V1.0"), + new TerritoryDefinition(799, "Psiscape V2.0"), + new TerritoryDefinition(803, "Psiscape V2.0"), + new TerritoryDefinition(963, "Radz-at-Han"), + new TerritoryDefinition(245, "Ragnarok Central Core"), + new TerritoryDefinition(244, "Ragnarok Drive Cylinder"), + new TerritoryDefinition(247, "Ragnarok Main Bridge"), + new TerritoryDefinition(1258, "Rebel Ring"), + new TerritoryDefinition(1259, "Rebel Ring"), + new TerritoryDefinition(1270, "Recollection"), + new TerritoryDefinition(1271, "Recollection"), + new TerritoryDefinition(1275, "Recollection"), + new TerritoryDefinition(764, "Reisen Temple"), + new TerritoryDefinition(781, "Reisen Temple Road"), + new TerritoryDefinition(1057, "Restricted Archives"), + new TerritoryDefinition(635, "Rhalgr's Reach"), + new TerritoryDefinition(659, "Rhalgr's Reach"), + new TerritoryDefinition(286, "Rhotano Sea"), + new TerritoryDefinition(288, "Rhotano Sea"), + new TerritoryDefinition(407, "Rhotano Sea"), + new TerritoryDefinition(708, "Rhotano Sea"), + new TerritoryDefinition(737, "Royal Palace"), + new TerritoryDefinition(639, "Ruby Bazaar Offices"), + new TerritoryDefinition(440, "Ruling Chamber"), + new TerritoryDefinition(456, "Ruling Chamber"), + new TerritoryDefinition(462, "Sacrificial Chamber"), + new TerritoryDefinition(427, "Saint Endalim's Scholasticate"), + new TerritoryDefinition(511, "Saint Mocianne's Arboretum"), + new TerritoryDefinition(788, "Saint Mocianne's Arboretum"), + new TerritoryDefinition(1022, "Saint Mocianne's Arboretum"), + new TerritoryDefinition(1304, "San d'Oria: The Second Walk"), + new TerritoryDefinition(392, "Sanctum of the Twelve"), + new TerritoryDefinition(393, "Sanctum of the Twelve"), + new TerritoryDefinition(741, "Sanctum of the Twelve"), + new TerritoryDefinition(387, "Sastasha"), + new TerritoryDefinition(1016, "Sastasha"), + new TerritoryDefinition(1036, "Sastasha"), + new TerritoryDefinition(1225, "Scratching Ring"), + new TerritoryDefinition(1226, "Scratching Ring"), + new TerritoryDefinition(431, "Seal Rock"), + new TerritoryDefinition(701, "Seal Rock"), + new TerritoryDefinition(204, "Seat of the First Bow"), + new TerritoryDefinition(428, "Seat of the Lord Commander"), + new TerritoryDefinition(1160, "Senatus"), + new TerritoryDefinition(1190, "Shaaloani"), + new TerritoryDefinition(1244, "Shaaloani"), + new TerritoryDefinition(641, "Shirogane"), + new TerritoryDefinition(616, "Shisui of the Violet Tides"), + new TerritoryDefinition(748, "Sigmascape V1.0"), + new TerritoryDefinition(752, "Sigmascape V1.0"), + new TerritoryDefinition(749, "Sigmascape V2.0"), + new TerritoryDefinition(753, "Sigmascape V2.0"), + new TerritoryDefinition(750, "Sigmascape V3.0"), + new TerritoryDefinition(754, "Sigmascape V3.0"), + new TerritoryDefinition(751, "Sigmascape V4.0"), + new TerritoryDefinition(755, "Sigmascape V4.0"), + new TerritoryDefinition(437, "Singularity Reactor"), + new TerritoryDefinition(448, "Singularity Reactor"), + new TerritoryDefinition(1237, "Sinus Ardorum"), + new TerritoryDefinition(1222, "Skydeep Cenote Inner Chamber"), + new TerritoryDefinition(976, "Smileton"), + new TerritoryDefinition(1094, "Sneaky Hollow"), + new TerritoryDefinition(1062, "Snowcloak"), + new TerritoryDefinition(617, "Sohm Al"), + new TerritoryDefinition(673, "Sohm Al"), + new TerritoryDefinition(1064, "Sohm Al"), + new TerritoryDefinition(1112, "Sohr Khai"), + new TerritoryDefinition(1186, "Solution Nine"), + new TerritoryDefinition(1213, "Solution Nine"), + new TerritoryDefinition(583, "Soul of the Creator"), + new TerritoryDefinition(587, "Soul of the Creator"), + new TerritoryDefinition(1252, "South Horn"), + new TerritoryDefinition(153, "South Shroud"), + new TerritoryDefinition(192, "South Shroud"), + new TerritoryDefinition(220, "South Shroud"), + new TerritoryDefinition(229, "South Shroud"), + new TerritoryDefinition(231, "South Shroud"), + new TerritoryDefinition(232, "South Shroud"), + new TerritoryDefinition(235, "South Shroud"), + new TerritoryDefinition(236, "South Shroud"), + new TerritoryDefinition(291, "South Shroud"), + new TerritoryDefinition(317, "South Shroud"), + new TerritoryDefinition(394, "South Shroud"), + new TerritoryDefinition(473, "South Shroud"), + new TerritoryDefinition(146, "Southern Thanalan"), + new TerritoryDefinition(260, "Southern Thanalan"), + new TerritoryDefinition(261, "Southern Thanalan"), + new TerritoryDefinition(306, "Southern Thanalan"), + new TerritoryDefinition(312, "Southern Thanalan"), + new TerritoryDefinition(318, "Southern Thanalan"), + new TerritoryDefinition(323, "Southern Thanalan"), + new TerritoryDefinition(491, "Southern Thanalan"), + new TerritoryDefinition(669, "Southern Thanalan"), + new TerritoryDefinition(1236, "Southern Thanalan"), + new TerritoryDefinition(942, "Sphere of Naught"), + new TerritoryDefinition(946, "Sphere of Naught"), + new TerritoryDefinition(1241, "Sphere of Naught"), + new TerritoryDefinition(1247, "Starlight Stalls"), + new TerritoryDefinition(1253, "Starlight Stalls"), + new TerritoryDefinition(1309, "Starlight Stalls"), + new TerritoryDefinition(559, "Steps of Faith"), + new TerritoryDefinition(566, "Steps of Faith"), + new TerritoryDefinition(569, "Steps of Faith"), + new TerritoryDefinition(1068, "Steps of Faith"), + new TerritoryDefinition(365, "Stone Vigil"), + new TerritoryDefinition(1042, "Stone Vigil"), + new TerritoryDefinition(1071, "Storm's Crown"), + new TerritoryDefinition(1072, "Storm's Crown"), + new TerritoryDefinition(1092, "Storm's Crown"), + new TerritoryDefinition(1001, "Strategy Room"), + new TerritoryDefinition(1204, "Strayborough"), + new TerritoryDefinition(1087, "Stygian Insenescence Cells"), + new TerritoryDefinition(1088, "Stygian Insenescence Cells"), + new TerritoryDefinition(1093, "Stygian Insenescence Cells"), + new TerritoryDefinition(610, "Sultana's Breath Apartment"), + new TerritoryDefinition(575, "Sultana's Breath Apartment Lobby"), + new TerritoryDefinition(1200, "Summit of Everkeep"), + new TerritoryDefinition(1201, "Summit of Everkeep"), + new TerritoryDefinition(1220, "Summit of Everkeep"), + new TerritoryDefinition(1170, "Sunperch"), + new TerritoryDefinition(1210, "Sunperch"), + new TerritoryDefinition(1098, "Sylphstep"), + new TerritoryDefinition(372, "Syrcus Tower"), + new TerritoryDefinition(1203, "Tender Valley"), + new TerritoryDefinition(919, "Terncliff"), + new TerritoryDefinition(925, "Terncliff Bay"), + new TerritoryDefinition(926, "Terncliff Bay"), + new TerritoryDefinition(1178, "Thaleia"), + new TerritoryDefinition(1182, "Thaleia"), + new TerritoryDefinition(957, "Thavnair"), + new TerritoryDefinition(900, "The Endeavor"), + new TerritoryDefinition(1163, "The Endeavor"), + new TerritoryDefinition(680, "The Misery"), + new TerritoryDefinition(736, "The Prima Vista Bridge"), + new TerritoryDefinition(735, "The Prima Vista Tiring Room"), + new TerritoryDefinition(828, "The Prima Vista Tiring Room"), + new TerritoryDefinition(1168, "The Abyssal Fracture"), + new TerritoryDefinition(1169, "The Abyssal Fracture"), + new TerritoryDefinition(1181, "The Abyssal Fracture"), + new TerritoryDefinition(1065, "The Aery"), + new TerritoryDefinition(1126, "The Aetherfont"), + new TerritoryDefinition(1177, "The Aetherfont"), + new TerritoryDefinition(1147, "The Aetherial Slough"), + new TerritoryDefinition(1148, "The Aetherial Slough"), + new TerritoryDefinition(1295, "The Ageless Necropolis"), + new TerritoryDefinition(1296, "The Ageless Necropolis"), + new TerritoryDefinition(1312, "The Ageless Necropolis"), + new TerritoryDefinition(978, "The Aitiascope"), + new TerritoryDefinition(1079, "The Aitiascope"), + new TerritoryDefinition(1111, "The Antitower"), + new TerritoryDefinition(558, "The Aquapolis"), + new TerritoryDefinition(444, "The Arm of the Father"), + new TerritoryDefinition(451, "The Arm of the Father"), + new TerritoryDefinition(522, "The Arm of the Son"), + new TerritoryDefinition(531, "The Arm of the Son"), + new TerritoryDefinition(622, "The Azim Steppe"), + new TerritoryDefinition(688, "The Azim Steppe"), + new TerritoryDefinition(713, "The Azim Steppe"), + new TerritoryDefinition(718, "The Azim Steppe"), + new TerritoryDefinition(723, "The Azim Steppe"), + new TerritoryDefinition(797, "The Azim Steppe"), + new TerritoryDefinition(1207, "The Backroom"), + new TerritoryDefinition(579, "The Battlehall"), + new TerritoryDefinition(940, "The Battlehall"), + new TerritoryDefinition(941, "The Battlehall"), + new TerritoryDefinition(1293, "The Bayside Battleground"), + new TerritoryDefinition(1294, "The Bayside Battleground"), + new TerritoryDefinition(733, "The Binding Coil of Bahamut"), + new TerritoryDefinition(674, "The Blessed Treasury"), + new TerritoryDefinition(677, "The Blessed Treasury"), + new TerritoryDefinition(445, "The Burden of the Father"), + new TerritoryDefinition(452, "The Burden of the Father"), + new TerritoryDefinition(523, "The Burden of the Son"), + new TerritoryDefinition(532, "The Burden of the Son"), + new TerritoryDefinition(769, "The Burn"), + new TerritoryDefinition(1173, "The Burn"), + new TerritoryDefinition(196, "The Burning Heart"), + new TerritoryDefinition(1081, "The Caustic Purgatory"), + new TerritoryDefinition(1082, "The Caustic Purgatory"), + new TerritoryDefinition(1151, "The Chamber of Fourteen"), + new TerritoryDefinition(1152, "The Chamber of Fourteen"), + new TerritoryDefinition(426, "The Chrysalis"), + new TerritoryDefinition(400, "The Churning Mists"), + new TerritoryDefinition(501, "The Churning Mists"), + new TerritoryDefinition(715, "The Churning Mists"), + new TerritoryDefinition(1116, "The Clockwork Castletown"), + new TerritoryDefinition(1117, "The Clockwork Castletown"), + new TerritoryDefinition(859, "The Confessional of Toupasa the Elder"), + new TerritoryDefinition(882, "The Copied Factory"), + new TerritoryDefinition(896, "The Copied Factory"), + new TerritoryDefinition(849, "The Core"), + new TerritoryDefinition(853, "The Core"), + new TerritoryDefinition(857, "The Core"), + new TerritoryDefinition(846, "The Crown of the Immaculate"), + new TerritoryDefinition(848, "The Crown of the Immaculate"), + new TerritoryDefinition(880, "The Crown of the Immaculate"), + new TerritoryDefinition(819, "The Crystarium"), + new TerritoryDefinition(443, "The Cuff of the Father"), + new TerritoryDefinition(450, "The Cuff of the Father"), + new TerritoryDefinition(521, "The Cuff of the Son"), + new TerritoryDefinition(530, "The Cuff of the Son"), + new TerritoryDefinition(354, "The Dancing Plague"), + new TerritoryDefinition(845, "The Dancing Plague"), + new TerritoryDefinition(858, "The Dancing Plague"), + new TerritoryDefinition(873, "The Dancing Plague"), + new TerritoryDefinition(992, "The Dark Inside"), + new TerritoryDefinition(993, "The Dark Inside"), + new TerritoryDefinition(1028, "The Dark Inside"), + new TerritoryDefinition(973, "The Dead Ends"), + new TerritoryDefinition(512, "The Diadem"), + new TerritoryDefinition(514, "The Diadem"), + new TerritoryDefinition(515, "The Diadem"), + new TerritoryDefinition(624, "The Diadem"), + new TerritoryDefinition(625, "The Diadem"), + new TerritoryDefinition(656, "The Diadem"), + new TerritoryDefinition(901, "The Diadem"), + new TerritoryDefinition(929, "The Diadem"), + new TerritoryDefinition(939, "The Diadem"), + new TerritoryDefinition(682, "The Doman Enclave"), + new TerritoryDefinition(739, "The Doman Enclave"), + new TerritoryDefinition(759, "The Doman Enclave"), + new TerritoryDefinition(398, "The Dravanian Forelands"), + new TerritoryDefinition(464, "The Dravanian Forelands"), + new TerritoryDefinition(481, "The Dravanian Forelands"), + new TerritoryDefinition(482, "The Dravanian Forelands"), + new TerritoryDefinition(1023, "The Dravanian Forelands"), + new TerritoryDefinition(399, "The Dravanian Hinterlands"), + new TerritoryDefinition(476, "The Dravanian Hinterlands"), + new TerritoryDefinition(485, "The Dravanian Hinterlands"), + new TerritoryDefinition(503, "The Dravanian Hinterlands"), + new TerritoryDefinition(1172, "The Drowned City of Skalla"), + new TerritoryDefinition(879, "The Dungeons of Lyhe Ghiah"), + new TerritoryDefinition(847, "The Dying Gasp"), + new TerritoryDefinition(881, "The Dying Gasp"), + new TerritoryDefinition(885, "The Dying Gasp"), + new TerritoryDefinition(1149, "The Dæmons' Nest"), + new TerritoryDefinition(1150, "The Dæmons' Nest"), + new TerritoryDefinition(1158, "The Dæmons' Nest"), + new TerritoryDefinition(504, "The Eighteenth Floor"), + new TerritoryDefinition(878, "The Empty"), + new TerritoryDefinition(965, "The Empty"), + new TerritoryDefinition(1000, "The Excitatron 6000"), + new TerritoryDefinition(792, "The Fall of Belah'dia"), + new TerritoryDefinition(899, "The Falling City of Nym"), + new TerritoryDefinition(149, "The Feasting Grounds"), + new TerritoryDefinition(1070, "The Fell Court of Troia"), + new TerritoryDefinition(1089, "The Fell Court of Troia"), + new TerritoryDefinition(1091, "The Fell Court of Troia"), + new TerritoryDefinition(1006, "The Fervid Limbo"), + new TerritoryDefinition(1007, "The Fervid Limbo"), + new TerritoryDefinition(554, "The Fields of Glory"), + new TerritoryDefinition(997, "The Final Day"), + new TerritoryDefinition(998, "The Final Day"), + new TerritoryDefinition(1029, "The Final Day"), + new TerritoryDefinition(886, "The Firmament"), + new TerritoryDefinition(683, "The First Altar of Djanan Qhat"), + new TerritoryDefinition(442, "The Fist of the Father"), + new TerritoryDefinition(449, "The Fist of the Father"), + new TerritoryDefinition(520, "The Fist of the Son"), + new TerritoryDefinition(529, "The Fist of the Son"), + new TerritoryDefinition(537, "The Fold"), + new TerritoryDefinition(538, "The Fold"), + new TerritoryDefinition(539, "The Fold"), + new TerritoryDefinition(540, "The Fold"), + new TerritoryDefinition(541, "The Fold"), + new TerritoryDefinition(542, "The Fold"), + new TerritoryDefinition(543, "The Fold"), + new TerritoryDefinition(544, "The Fold"), + new TerritoryDefinition(545, "The Fold"), + new TerritoryDefinition(546, "The Fold"), + new TerritoryDefinition(547, "The Fold"), + new TerritoryDefinition(548, "The Fold"), + new TerritoryDefinition(549, "The Fold"), + new TerritoryDefinition(550, "The Fold"), + new TerritoryDefinition(551, "The Fold"), + new TerritoryDefinition(1127, "The Fold"), + new TerritoryDefinition(1128, "The Fold"), + new TerritoryDefinition(1129, "The Fold"), + new TerritoryDefinition(1205, "The For'ard Cabins"), + new TerritoryDefinition(430, "The Fractal Continuum"), + new TerritoryDefinition(743, "The Fractal Continuum"), + new TerritoryDefinition(612, "The Fringes"), + new TerritoryDefinition(640, "The Fringes"), + new TerritoryDefinition(647, "The Fringes"), + new TerritoryDefinition(648, "The Fringes"), + new TerritoryDefinition(670, "The Fringes"), + new TerritoryDefinition(671, "The Fringes"), + new TerritoryDefinition(678, "The Fringes"), + new TerritoryDefinition(703, "The Fringes"), + new TerritoryDefinition(760, "The Fringes"), + new TerritoryDefinition(902, "The Gandof Thunder Plains"), + new TerritoryDefinition(906, "The Gandof Thunder Plains"), + new TerritoryDefinition(945, "The Garden of Nowhere"), + new TerritoryDefinition(949, "The Garden of Nowhere"), + new TerritoryDefinition(1002, "The Gates of Pandæmonium"), + new TerritoryDefinition(1003, "The Gates of Pandæmonium"), + new TerritoryDefinition(1025, "The Gates of Pandæmonium"), + new TerritoryDefinition(830, "The Ghimlyt Dark"), + new TerritoryDefinition(1174, "The Ghimlyt Dark"), + new TerritoryDefinition(509, "The Gilded Araya"), + new TerritoryDefinition(1136, "The Gilded Araya"), + new TerritoryDefinition(1183, "The Gilded Araya"), + new TerritoryDefinition(341, "The Goblet"), + new TerritoryDefinition(144, "The Gold Saucer"), + new TerritoryDefinition(832, "The Gold Saucer"), + new TerritoryDefinition(884, "The Grand Cosmos"), + new TerritoryDefinition(578, "The Great Gubal Library"), + new TerritoryDefinition(676, "The Great Gubal Library"), + new TerritoryDefinition(1109, "The Great Gubal Library"), + new TerritoryDefinition(761, "The Great Hunt"), + new TerritoryDefinition(762, "The Great Hunt"), + new TerritoryDefinition(850, "The Halo"), + new TerritoryDefinition(854, "The Halo"), + new TerritoryDefinition(904, "The Halo"), + new TerritoryDefinition(908, "The Halo"), + new TerritoryDefinition(916, "The Heroes' Gauntlet"), + new TerritoryDefinition(1085, "The Hollow Purgatory"), + new TerritoryDefinition(1086, "The Hollow Purgatory"), + new TerritoryDefinition(358, "The Holocharts"), + new TerritoryDefinition(383, "The Holocharts"), + new TerritoryDefinition(178, "The Hourglass"), + new TerritoryDefinition(681, "The House of the Fierce"), + new TerritoryDefinition(294, "The Howling Eye"), + new TerritoryDefinition(297, "The Howling Eye"), + new TerritoryDefinition(331, "The Howling Eye"), + new TerritoryDefinition(833, "The Howling Eye"), + new TerritoryDefinition(834, "The Howling Eye"), + new TerritoryDefinition(1047, "The Howling Eye"), + new TerritoryDefinition(893, "The Imperial Palace"), + new TerritoryDefinition(658, "The Interdimensional Rift"), + new TerritoryDefinition(690, "The Interdimensional Rift"), + new TerritoryDefinition(724, "The Interdimensional Rift"), + new TerritoryDefinition(756, "The Interdimensional Rift"), + new TerritoryDefinition(800, "The Interdimensional Rift"), + new TerritoryDefinition(801, "The Interdimensional Rift"), + new TerritoryDefinition(804, "The Interdimensional Rift"), + new TerritoryDefinition(805, "The Interdimensional Rift"), + new TerritoryDefinition(807, "The Interdimensional Rift"), + new TerritoryDefinition(808, "The Interdimensional Rift"), + new TerritoryDefinition(812, "The Interdimensional Rift"), + new TerritoryDefinition(1122, "The Interdimensional Rift"), + new TerritoryDefinition(746, "The Jade Stoa"), + new TerritoryDefinition(758, "The Jade Stoa"), + new TerritoryDefinition(1063, "The Keeper of the Lake"), + new TerritoryDefinition(955, "The Last Trace"), + new TerritoryDefinition(964, "The Last Trace"), + new TerritoryDefinition(340, "The Lavender Beds"), + new TerritoryDefinition(439, "The Lightfeather Proving Grounds"), + new TerritoryDefinition(436, "The Limitless Blue"), + new TerritoryDefinition(447, "The Limitless Blue"), + new TerritoryDefinition(621, "The Lochs"), + new TerritoryDefinition(684, "The Lochs"), + new TerritoryDefinition(686, "The Lochs"), + new TerritoryDefinition(687, "The Lochs"), + new TerritoryDefinition(712, "The Lost Canals of Uznair"), + new TerritoryDefinition(725, "The Lost Canals of Uznair"), + new TerritoryDefinition(363, "The Lost City of Amdapor"), + new TerritoryDefinition(519, "The Lost City of Amdapor"), + new TerritoryDefinition(722, "The Lost City of Amdapor"), + new TerritoryDefinition(1164, "The Lunar Subterrane"), + new TerritoryDefinition(1184, "The Lunar Subterrane"), + new TerritoryDefinition(831, "The Manderville Tables"), + new TerritoryDefinition(1166, "The Memory of Embers"), + new TerritoryDefinition(1292, "The Meso Terminal"), + new TerritoryDefinition(995, "The Mothercrystal"), + new TerritoryDefinition(996, "The Mothercrystal"), + new TerritoryDefinition(1030, "The Mothercrystal"), + new TerritoryDefinition(876, "The Nabaath Mines"), + new TerritoryDefinition(293, "The Navel"), + new TerritoryDefinition(296, "The Navel"), + new TerritoryDefinition(954, "The Navel"), + new TerritoryDefinition(1046, "The Navel"), + new TerritoryDefinition(851, "The Nereus Trench"), + new TerritoryDefinition(855, "The Nereus Trench"), + new TerritoryDefinition(1024, "The Nethergate"), + new TerritoryDefinition(844, "The Ocular"), + new TerritoryDefinition(1061, "The Omphalos"), + new TerritoryDefinition(826, "The Orbonne Monastery"), + new TerritoryDefinition(356, "The Outer Coil"), + new TerritoryDefinition(381, "The Outer Coil"), + new TerritoryDefinition(561, "The Palace of the Dead"), + new TerritoryDefinition(562, "The Palace of the Dead"), + new TerritoryDefinition(563, "The Palace of the Dead"), + new TerritoryDefinition(564, "The Palace of the Dead"), + new TerritoryDefinition(565, "The Palace of the Dead"), + new TerritoryDefinition(570, "The Palace of the Dead"), + new TerritoryDefinition(593, "The Palace of the Dead"), + new TerritoryDefinition(594, "The Palace of the Dead"), + new TerritoryDefinition(595, "The Palace of the Dead"), + new TerritoryDefinition(596, "The Palace of the Dead"), + new TerritoryDefinition(597, "The Palace of the Dead"), + new TerritoryDefinition(598, "The Palace of the Dead"), + new TerritoryDefinition(599, "The Palace of the Dead"), + new TerritoryDefinition(600, "The Palace of the Dead"), + new TerritoryDefinition(601, "The Palace of the Dead"), + new TerritoryDefinition(602, "The Palace of the Dead"), + new TerritoryDefinition(603, "The Palace of the Dead"), + new TerritoryDefinition(604, "The Palace of the Dead"), + new TerritoryDefinition(605, "The Palace of the Dead"), + new TerritoryDefinition(606, "The Palace of the Dead"), + new TerritoryDefinition(607, "The Palace of the Dead"), + new TerritoryDefinition(1032, "The Palaistra"), + new TerritoryDefinition(1058, "The Palaistra"), + new TerritoryDefinition(567, "The Parrock"), + new TerritoryDefinition(620, "The Peaks"), + new TerritoryDefinition(716, "The Peaks"), + new TerritoryDefinition(868, "The Peaks"), + new TerritoryDefinition(1019, "The Peaks"), + new TerritoryDefinition(843, "The Pendants Personal Suite"), + new TerritoryDefinition(1083, "The Pestilent Purgatory"), + new TerritoryDefinition(1084, "The Pestilent Purgatory"), + new TerritoryDefinition(994, "The Phantoms' Feast"), + new TerritoryDefinition(419, "The Pillars"), + new TerritoryDefinition(499, "The Pillars"), + new TerritoryDefinition(1052, "The Porta Decumana"), + new TerritoryDefinition(1053, "The Porta Decumana"), + new TerritoryDefinition(1044, "The Praetorium"), + new TerritoryDefinition(917, "The Puppets' Bunker"), + new TerritoryDefinition(928, "The Puppets' Bunker"), + new TerritoryDefinition(823, "The Qitana Ravel"), + new TerritoryDefinition(243, "The Ragnarok"), + new TerritoryDefinition(817, "The Rak'tika Greatwood"), + new TerritoryDefinition(871, "The Rak'tika Greatwood"), + new TerritoryDefinition(874, "The Rak'tika Greatwood"), + new TerritoryDefinition(875, "The Rak'tika Greatwood"), + new TerritoryDefinition(1162, "The Red Moon"), + new TerritoryDefinition(1138, "The Red Sands"), + new TerritoryDefinition(1139, "The Red Sands"), + new TerritoryDefinition(738, "The Resonatorium"), + new TerritoryDefinition(787, "The Ridorana Cataract"), + new TerritoryDefinition(776, "The Ridorana Lighthouse"), + new TerritoryDefinition(351, "The Rising Stones"), + new TerritoryDefinition(179, "The Roost"), + new TerritoryDefinition(679, "The Royal Airship Landing"), + new TerritoryDefinition(734, "The Royal City of Rabanastre"), + new TerritoryDefinition(727, "The Royal Menagerie"), + new TerritoryDefinition(740, "The Royal Menagerie"), + new TerritoryDefinition(613, "The Ruby Sea"), + new TerritoryDefinition(657, "The Ruby Sea"), + new TerritoryDefinition(711, "The Ruby Sea"), + new TerritoryDefinition(726, "The Ruby Sea"), + new TerritoryDefinition(757, "The Ruby Sea"), + new TerritoryDefinition(1008, "The Sanguine Limbo"), + new TerritoryDefinition(1009, "The Sanguine Limbo"), + new TerritoryDefinition(401, "The Sea of Clouds"), + new TerritoryDefinition(455, "The Sea of Clouds"), + new TerritoryDefinition(461, "The Sea of Clouds"), + new TerritoryDefinition(492, "The Sea of Clouds"), + new TerritoryDefinition(1214, "The Sea of Clouds"), + new TerritoryDefinition(922, "The Seat of Sacrifice"), + new TerritoryDefinition(923, "The Seat of Sacrifice"), + new TerritoryDefinition(931, "The Seat of Sacrifice"), + new TerritoryDefinition(794, "The Shifting Altars of Uznair"), + new TerritoryDefinition(1123, "The Shifting Gymnasion Agonon"), + new TerritoryDefinition(924, "The Shifting Oubliettes of Lyhe Ghiah"), + new TerritoryDefinition(1069, "The Sil'dihn Subterrane"), + new TerritoryDefinition(1142, "The Sirensong Sea"), + new TerritoryDefinition(1194, "The Skydeep Cenote"), + new TerritoryDefinition(1004, "The Stagnant Limbo"), + new TerritoryDefinition(1005, "The Stagnant Limbo"), + new TerritoryDefinition(986, "The Stigma Dreamscape"), + new TerritoryDefinition(374, "The Striking Tree"), + new TerritoryDefinition(375, "The Striking Tree"), + new TerritoryDefinition(367, "The Sunken Temple of Qarn"), + new TerritoryDefinition(1267, "The Sunken Temple of Qarn"), + new TerritoryDefinition(768, "The Swallow's Compass"), + new TerritoryDefinition(1017, "The Swallow's Compass"), + new TerritoryDefinition(842, "The Syrcus Trench"), + new TerritoryDefinition(373, "The Tam-Tara Deepcroft"), + new TerritoryDefinition(1037, "The Tam-Tara Deepcroft"), + new TerritoryDefinition(818, "The Tempest"), + new TerritoryDefinition(932, "The Tempest"), + new TerritoryDefinition(663, "The Temple of the Fist"), + new TerritoryDefinition(1039, "The Thousand Maws of Toto-Rak"), + new TerritoryDefinition(1231, "The Thundering"), + new TerritoryDefinition(1232, "The Thundering"), + new TerritoryDefinition(966, "The Tower at Paradigm's Breach"), + new TerritoryDefinition(969, "The Tower of Babil"), + new TerritoryDefinition(1051, "The Tower of Babil"), + new TerritoryDefinition(1115, "The Tower of Babil"), + new TerritoryDefinition(952, "The Tower of Zot"), + new TerritoryDefinition(840, "The Twinning"), + new TerritoryDefinition(1266, "The Underkeep"), + new TerritoryDefinition(513, "The Vault"), + new TerritoryDefinition(1018, "The Vault"), + new TerritoryDefinition(1066, "The Vault"), + new TerritoryDefinition(1140, "The Voidcast Dais"), + new TerritoryDefinition(1141, "The Voidcast Dais"), + new TerritoryDefinition(1159, "The Voidcast Dais"), + new TerritoryDefinition(1033, "The Volcanic Heart"), + new TerritoryDefinition(1059, "The Volcanic Heart"), + new TerritoryDefinition(212, "The Waking Sands"), + new TerritoryDefinition(159, "The Wanderer's Palace"), + new TerritoryDefinition(188, "The Wanderer's Palace"), + new TerritoryDefinition(329, "The Wanderer's Palace"), + new TerritoryDefinition(556, "The Weeping City of Mhach"), + new TerritoryDefinition(707, "The Weeping City of Mhach"), + new TerritoryDefinition(368, "The Weeping Saint"), + new TerritoryDefinition(281, "The Whorleater"), + new TerritoryDefinition(359, "The Whorleater"), + new TerritoryDefinition(1300, "The Windward Wilds"), + new TerritoryDefinition(1301, "The Windward Wilds"), + new TerritoryDefinition(1306, "The Windward Wilds"), + new TerritoryDefinition(151, "The World of Darkness"), + new TerritoryDefinition(824, "The Wreath of Snakes"), + new TerritoryDefinition(825, "The Wreath of Snakes"), + new TerritoryDefinition(1302, "The Wreath of Snakes"), + new TerritoryDefinition(432, "Thok ast Thok"), + new TerritoryDefinition(446, "Thok ast Thok"), + new TerritoryDefinition(364, "Thornmarch"), + new TerritoryDefinition(1067, "Thornmarch"), + new TerritoryDefinition(1274, "Throne Room"), + new TerritoryDefinition(608, "Topmast Apartment"), + new TerritoryDefinition(573, "Topmast Apartment Lobby"), + new TerritoryDefinition(913, "Transmission Control"), + new TerritoryDefinition(730, "Transparency"), + new TerritoryDefinition(914, "Trial's Threshold"), + new TerritoryDefinition(1223, "Tritails Training"), + new TerritoryDefinition(1185, "Tuliyollal"), + new TerritoryDefinition(534, "Twin Adder Barracks"), + new TerritoryDefinition(130, "Ul'dah - Steps of Nald"), + new TerritoryDefinition(182, "Ul'dah - Steps of Nald"), + new TerritoryDefinition(251, "Ul'dah - Steps of Nald"), + new TerritoryDefinition(254, "Ul'dah - Steps of Nald"), + new TerritoryDefinition(259, "Ul'dah - Steps of Nald"), + new TerritoryDefinition(274, "Ul'dah - Steps of Nald"), + new TerritoryDefinition(790, "Ul'dah - Steps of Nald"), + new TerritoryDefinition(131, "Ul'dah - Steps of Thal"), + new TerritoryDefinition(666, "Ul'dah - Steps of Thal"), + new TerritoryDefinition(705, "Ul'dah - Steps of Thal"), + new TerritoryDefinition(706, "Ul'dah - Steps of Thal"), + new TerritoryDefinition(960, "Ultima Thule"), + new TerritoryDefinition(1027, "Ultima Thule"), + new TerritoryDefinition(777, "Ultimacy"), + new TerritoryDefinition(1055, "Unnamed Island"), + new TerritoryDefinition(241, "Upper Aetheroacoustic Exploratory Site"), + new TerritoryDefinition(139, "Upper La Noscea"), + new TerritoryDefinition(221, "Upper La Noscea"), + new TerritoryDefinition(328, "Upper La Noscea"), + new TerritoryDefinition(412, "Upper La Noscea"), + new TerritoryDefinition(454, "Upper La Noscea"), + new TerritoryDefinition(466, "Upper La Noscea"), + new TerritoryDefinition(1187, "Urqopacha"), + new TerritoryDefinition(970, "Vanaspati"), + new TerritoryDefinition(1198, "Vanguard"), + new TerritoryDefinition(1219, "Vanguard"), + new TerritoryDefinition(1279, "Vault Oneiron"), + new TerritoryDefinition(508, "Void Ark"), + new TerritoryDefinition(138, "Western La Noscea"), + new TerritoryDefinition(263, "Western La Noscea"), + new TerritoryDefinition(280, "Western La Noscea"), + new TerritoryDefinition(330, "Western La Noscea"), + new TerritoryDefinition(405, "Western La Noscea"), + new TerritoryDefinition(406, "Western La Noscea"), + new TerritoryDefinition(413, "Western La Noscea"), + new TerritoryDefinition(453, "Western La Noscea"), + new TerritoryDefinition(552, "Western La Noscea"), + new TerritoryDefinition(675, "Western La Noscea"), + new TerritoryDefinition(140, "Western Thanalan"), + new TerritoryDefinition(215, "Western Thanalan"), + new TerritoryDefinition(255, "Western Thanalan"), + new TerritoryDefinition(267, "Western Thanalan"), + new TerritoryDefinition(269, "Western Thanalan"), + new TerritoryDefinition(273, "Western Thanalan"), + new TerritoryDefinition(278, "Western Thanalan"), + new TerritoryDefinition(1049, "Western Thanalan"), + new TerritoryDefinition(250, "Wolves' Den Pier"), + new TerritoryDefinition(717, "Wolves' Den Pier"), + new TerritoryDefinition(1195, "Worqor Lar Dor"), + new TerritoryDefinition(1196, "Worqor Lar Dor"), + new TerritoryDefinition(1193, "Worqor Zormor"), + new TerritoryDefinition(1113, "Xelphatol"), + new TerritoryDefinition(1189, "Yak T'el"), + new TerritoryDefinition(1211, "Yak T'el"), + new TerritoryDefinition(1212, "Yak T'el"), + new TerritoryDefinition(614, "Yanxia"), + new TerritoryDefinition(634, "Yanxia"), + new TerritoryDefinition(685, "Yanxia"), + new TerritoryDefinition(1242, "Yuweyawata"), + new TerritoryDefinition(1254, "Yuweyawata"), + new TerritoryDefinition(975, "Zadnor"), + new TerritoryDefinition(1077, "Zero's Domain"), + new TerritoryDefinition(1297, "Zirgorteh the Open-armed"), + new TerritoryDefinition(1246, "Zorgor the Boundless"), + }; + + public static IReadOnlyList All { get; } = Array.AsReadOnly(TerritoryArray); + public static IReadOnlyDictionary ById { get; } = new ReadOnlyDictionary(TerritoryArray.ToDictionary(t => t.TerritoryId)); + public static IReadOnlyDictionary> IdsByName { get; } = new ReadOnlyDictionary>(TerritoryArray + .GroupBy(t => t.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + g => g.Key, + g => (IReadOnlySet)new HashSet(g.Select(t => t.TerritoryId)), + StringComparer.OrdinalIgnoreCase)); + + public static bool TryGetIds(string name, out IReadOnlySet ids) + => IdsByName.TryGetValue(name, out ids); + + public static IReadOnlySet GetIds(IEnumerable names) + { + var set = new HashSet(); + foreach (var name in names) + { + if (string.IsNullOrWhiteSpace(name)) + continue; + + if (IdsByName.TryGetValue(name, out var ids)) + { + set.UnionWith(ids); + } + } + + return set; + } + + public static IReadOnlySet GetIds(params string[] names) + => GetIds((IEnumerable)names); +} diff --git a/LightlessSyncServer/LightlessSyncServer/Models/WorldDefinition.cs b/LightlessSyncServer/LightlessSyncServer/Models/WorldDefinition.cs new file mode 100644 index 0000000..919b6e8 --- /dev/null +++ b/LightlessSyncServer/LightlessSyncServer/Models/WorldDefinition.cs @@ -0,0 +1,7 @@ +namespace LightlessSyncServer.Models; + +internal readonly record struct WorldDefinition( + ushort WorldId, + string Name, + string Region, + string DataCenter); diff --git a/LightlessSyncServer/LightlessSyncServer/Models/WorldRegistry.generated.cs b/LightlessSyncServer/LightlessSyncServer/Models/WorldRegistry.generated.cs new file mode 100644 index 0000000..9fbcc75 --- /dev/null +++ b/LightlessSyncServer/LightlessSyncServer/Models/WorldRegistry.generated.cs @@ -0,0 +1,117 @@ +// +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +namespace LightlessSyncServer.Models; + +internal static class WorldRegistry +{ + private static readonly WorldDefinition[] WorldArray = new[] + { + new WorldDefinition(80, "Cerberus", "Europe", "Chaos"), + new WorldDefinition(83, "Louisoix", "Europe", "Chaos"), + new WorldDefinition(71, "Moogle", "Europe", "Chaos"), + new WorldDefinition(39, "Omega", "Europe", "Chaos"), + new WorldDefinition(401, "Phantom", "Europe", "Chaos"), + new WorldDefinition(97, "Ragnarok", "Europe", "Chaos"), + new WorldDefinition(400, "Sagittarius", "Europe", "Chaos"), + new WorldDefinition(85, "Spriggan", "Europe", "Chaos"), + new WorldDefinition(402, "Alpha", "Europe", "Light"), + new WorldDefinition(36, "Lich", "Europe", "Light"), + new WorldDefinition(66, "Odin", "Europe", "Light"), + new WorldDefinition(56, "Phoenix", "Europe", "Light"), + new WorldDefinition(403, "Raiden", "Europe", "Light"), + new WorldDefinition(67, "Shiva", "Europe", "Light"), + new WorldDefinition(33, "Twintania", "Europe", "Light"), + new WorldDefinition(42, "Zodiark", "Europe", "Light"), + new WorldDefinition(90, "Aegis", "Japan", "Elemental"), + new WorldDefinition(68, "Atomos", "Japan", "Elemental"), + new WorldDefinition(45, "Carbuncle", "Japan", "Elemental"), + new WorldDefinition(58, "Garuda", "Japan", "Elemental"), + new WorldDefinition(94, "Gungnir", "Japan", "Elemental"), + new WorldDefinition(49, "Kujata", "Japan", "Elemental"), + new WorldDefinition(72, "Tonberry", "Japan", "Elemental"), + new WorldDefinition(50, "Typhon", "Japan", "Elemental"), + new WorldDefinition(43, "Alexander", "Japan", "Gaia"), + new WorldDefinition(69, "Bahamut", "Japan", "Gaia"), + new WorldDefinition(92, "Durandal", "Japan", "Gaia"), + new WorldDefinition(46, "Fenrir", "Japan", "Gaia"), + new WorldDefinition(59, "Ifrit", "Japan", "Gaia"), + new WorldDefinition(98, "Ridill", "Japan", "Gaia"), + new WorldDefinition(76, "Tiamat", "Japan", "Gaia"), + new WorldDefinition(51, "Ultima", "Japan", "Gaia"), + new WorldDefinition(44, "Anima", "Japan", "Mana"), + new WorldDefinition(23, "Asura", "Japan", "Mana"), + new WorldDefinition(70, "Chocobo", "Japan", "Mana"), + new WorldDefinition(47, "Hades", "Japan", "Mana"), + new WorldDefinition(48, "Ixion", "Japan", "Mana"), + new WorldDefinition(96, "Masamune", "Japan", "Mana"), + new WorldDefinition(28, "Pandaemonium", "Japan", "Mana"), + new WorldDefinition(61, "Titan", "Japan", "Mana"), + new WorldDefinition(24, "Belias", "Japan", "Meteor"), + new WorldDefinition(82, "Mandragora", "Japan", "Meteor"), + new WorldDefinition(60, "Ramuh", "Japan", "Meteor"), + new WorldDefinition(29, "Shinryu", "Japan", "Meteor"), + new WorldDefinition(30, "Unicorn", "Japan", "Meteor"), + new WorldDefinition(52, "Valefor", "Japan", "Meteor"), + new WorldDefinition(31, "Yojimbo", "Japan", "Meteor"), + new WorldDefinition(32, "Zeromus", "Japan", "Meteor"), + new WorldDefinition(73, "Adamantoise", "North America", "Aether"), + new WorldDefinition(79, "Cactuar", "North America", "Aether"), + new WorldDefinition(54, "Faerie", "North America", "Aether"), + new WorldDefinition(63, "Gilgamesh", "North America", "Aether"), + new WorldDefinition(40, "Jenova", "North America", "Aether"), + new WorldDefinition(65, "Midgardsormr", "North America", "Aether"), + new WorldDefinition(99, "Sargatanas", "North America", "Aether"), + new WorldDefinition(57, "Siren", "North America", "Aether"), + new WorldDefinition(91, "Balmung", "North America", "Crystal"), + new WorldDefinition(34, "Brynhildr", "North America", "Crystal"), + new WorldDefinition(74, "Coeurl", "North America", "Crystal"), + new WorldDefinition(62, "Diabolos", "North America", "Crystal"), + new WorldDefinition(81, "Goblin", "North America", "Crystal"), + new WorldDefinition(75, "Malboro", "North America", "Crystal"), + new WorldDefinition(37, "Mateus", "North America", "Crystal"), + new WorldDefinition(41, "Zalera", "North America", "Crystal"), + new WorldDefinition(408, "Cuchulainn", "North America", "Dynamis"), + new WorldDefinition(411, "Golem", "North America", "Dynamis"), + new WorldDefinition(406, "Halicarnassus", "North America", "Dynamis"), + new WorldDefinition(409, "Kraken", "North America", "Dynamis"), + new WorldDefinition(407, "Maduin", "North America", "Dynamis"), + new WorldDefinition(404, "Marilith", "North America", "Dynamis"), + new WorldDefinition(410, "Rafflesia", "North America", "Dynamis"), + new WorldDefinition(405, "Seraph", "North America", "Dynamis"), + new WorldDefinition(78, "Behemoth", "North America", "Primal"), + new WorldDefinition(93, "Excalibur", "North America", "Primal"), + new WorldDefinition(53, "Exodus", "North America", "Primal"), + new WorldDefinition(35, "Famfrit", "North America", "Primal"), + new WorldDefinition(95, "Hyperion", "North America", "Primal"), + new WorldDefinition(55, "Lamia", "North America", "Primal"), + new WorldDefinition(64, "Leviathan", "North America", "Primal"), + new WorldDefinition(77, "Ultros", "North America", "Primal"), + new WorldDefinition(22, "Bismarck", "Oceania", "Materia"), + new WorldDefinition(21, "Ravana", "Oceania", "Materia"), + new WorldDefinition(86, "Sephirot", "Oceania", "Materia"), + new WorldDefinition(87, "Sophia", "Oceania", "Materia"), + new WorldDefinition(88, "Zurvan", "Oceania", "Materia"), + }; + + public static IReadOnlyList All { get; } = Array.AsReadOnly(WorldArray); + public static IReadOnlyDictionary ById { get; } = new ReadOnlyDictionary(WorldArray.ToDictionary(w => w.WorldId)); + public static IReadOnlyDictionary> ByDataCenter { get; } = new ReadOnlyDictionary>(WorldArray + .GroupBy(w => w.DataCenter, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + g => g.Key, + g => (IReadOnlyList)g.OrderBy(w => w.Name, StringComparer.Ordinal).ToArray(), + StringComparer.OrdinalIgnoreCase)); + public static IReadOnlyDictionary> ByRegion { get; } = new ReadOnlyDictionary>(WorldArray + .GroupBy(w => w.Region, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + g => g.Key, + g => (IReadOnlyList)g.OrderBy(w => w.Name, StringComparer.Ordinal).ToArray(), + StringComparer.OrdinalIgnoreCase)); + + public static bool TryGet(ushort worldId, out WorldDefinition definition) => ById.TryGetValue(worldId, out definition); + public static bool IsKnownWorld(ushort worldId) => ById.ContainsKey(worldId); +} diff --git a/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs b/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs index 0a411d1..9c47097 100644 --- a/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs +++ b/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs @@ -49,11 +49,24 @@ public sealed class ChatChannelService string userUid, ZoneChannelDefinition definition, ushort worldId, + ushort territoryId, string? hashedCid, bool isLightfinder, bool isActive) { - var descriptor = definition.Descriptor with { WorldId = worldId }; + if (worldId == 0 || !WorldRegistry.IsKnownWorld(worldId)) + { + _logger.LogWarning("Rejected zone chat presence for {User} in {Zone}: unknown world {WorldId}", userUid, definition.Key, worldId); + return null; + } + + if (!definition.TerritoryIds.Contains(territoryId)) + { + _logger.LogWarning("Rejected zone chat presence for {User} in {Zone}: invalid territory {TerritoryId}", userUid, definition.Key, territoryId); + return null; + } + + var descriptor = definition.Descriptor with { WorldId = worldId, ZoneId = territoryId }; var participant = new ChatParticipantInfo( Token: string.Empty, UserUid: userUid, From 50c9268e7619ba33ce0e1b62847e5e8271cb15a1 Mon Sep 17 00:00:00 2001 From: cake Date: Tue, 11 Nov 2025 18:59:50 +0100 Subject: [PATCH 5/5] updated submodule --- LightlessAPI | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LightlessAPI b/LightlessAPI index bb88bea..0170ac3 160000 --- a/LightlessAPI +++ b/LightlessAPI @@ -1 +1 @@ -Subproject commit bb88bea5aade4fb3fce9d5a729ba37102e68a4d6 +Subproject commit 0170ac377d7d2341c0d0e206ab871af22ac4767b