From 03ba9493fc191399497fc0479cf8c2c82c21b780 Mon Sep 17 00:00:00 2001 From: azyges <229218900+azyges@users.noreply.github.com> Date: Sun, 14 Dec 2025 15:13:22 +0900 Subject: [PATCH 1/4] prevent report dodging by leaving channel, allow reports to work after leaving the channel too --- .../Hubs/LightlessHub.Chat.cs | 6 - .../Services/ChatChannelService.cs | 105 +++++++++++++++++- .../Discord/DiscordBot.cs | 8 +- 3 files changed, 103 insertions(+), 16 deletions(-) diff --git a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs index 9fcabf0..ccc45dc 100644 --- a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs +++ b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs @@ -334,12 +334,6 @@ public partial class LightlessHub { 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); diff --git a/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs b/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs index 6c01cb7..fb9746f 100644 --- a/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs +++ b/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs @@ -1,31 +1,32 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Security.Cryptography; +using System.Security.Cryptography; using LightlessSync.API.Data; using LightlessSync.API.Dto.Chat; using LightlessSyncServer.Models; -using Microsoft.Extensions.Logging; namespace LightlessSyncServer.Services; -public sealed class ChatChannelService +public sealed class ChatChannelService : IDisposable { private readonly ILogger _logger; private readonly Dictionary _zoneDefinitions; private readonly Dictionary> _membersByChannel = new(); private readonly Dictionary> _presenceByUser = new(StringComparer.Ordinal); private readonly Dictionary> _participantsByChannel = new(); + private readonly Dictionary> _inactiveParticipantsByChannel = new(); private readonly Dictionary> _messagesByChannel = new(); private readonly Dictionary Node)> _messageIndex = new(StringComparer.Ordinal); private readonly object _syncRoot = new(); private const int MaxMessagesPerChannel = 200; + private static readonly TimeSpan InactiveParticipantRetention = TimeSpan.FromMinutes(15); + private static readonly TimeSpan InactiveParticipantCleanupInterval = TimeSpan.FromMinutes(1); + private readonly Timer _inactiveParticipantCleanupTimer; public ChatChannelService(ILogger logger) { _logger = logger; _zoneDefinitions = ChatZoneDefinitions.Defaults .ToDictionary(definition => definition.Key, StringComparer.OrdinalIgnoreCase); + _inactiveParticipantCleanupTimer = new Timer(_ => CleanupExpiredInactiveParticipants(), null, InactiveParticipantCleanupInterval, InactiveParticipantCleanupInterval); } public IReadOnlyList GetZoneChannelInfos() => @@ -271,6 +272,22 @@ public sealed class ChatChannelService { return true; } + + if (_inactiveParticipantsByChannel.TryGetValue(key, out var inactive) && + inactive.TryGetValue(token, out var inactiveEntry)) + { + if (inactiveEntry.ExpiresAt > DateTime.UtcNow) + { + participant = inactiveEntry.Participant; + return true; + } + + inactive.Remove(token); + if (inactive.Count == 0) + { + _inactiveParticipantsByChannel.Remove(key); + } + } } participant = default; @@ -412,6 +429,7 @@ public sealed class ChatChannelService } participantsByToken[token] = finalParticipant; + RemoveInactiveParticipantLocked(key, token); _logger.LogDebug("Chat presence updated for {User} in {Channel}", userUid, Describe(key)); return entryToStore; @@ -446,9 +464,77 @@ public sealed class ChatChannelService } } + StoreInactiveParticipantLocked(key, existing.Participant); return true; } + private void StoreInactiveParticipantLocked(ChannelKey key, ChatParticipantInfo participant) + { + if (string.IsNullOrEmpty(participant.Token)) + { + return; + } + + if (!_inactiveParticipantsByChannel.TryGetValue(key, out var inactive)) + { + inactive = new Dictionary(StringComparer.Ordinal); + _inactiveParticipantsByChannel[key] = inactive; + } + + inactive[participant.Token] = new InactiveParticipantEntry(participant, DateTime.UtcNow + InactiveParticipantRetention); + } + + private void RemoveInactiveParticipantLocked(ChannelKey key, string token) + { + if (_inactiveParticipantsByChannel.TryGetValue(key, out var inactive) && + inactive.Remove(token) && + inactive.Count == 0) + { + _inactiveParticipantsByChannel.Remove(key); + } + } + + private void CleanupExpiredInactiveParticipants() + { + lock (_syncRoot) + { + if (_inactiveParticipantsByChannel.Count == 0) + { + return; + } + + var now = DateTime.UtcNow; + var channelsToRemove = new List(); + + foreach (var (key, inactive) in _inactiveParticipantsByChannel) + { + var tokensToRemove = new List(); + foreach (var (token, entry) in inactive) + { + if (entry.ExpiresAt <= now) + { + tokensToRemove.Add(token); + } + } + + foreach (var token in tokensToRemove) + { + inactive.Remove(token); + } + + if (inactive.Count == 0) + { + channelsToRemove.Add(key); + } + } + + foreach (var channel in channelsToRemove) + { + _inactiveParticipantsByChannel.Remove(channel); + } + } + } + private static string GenerateToken() { Span buffer = stackalloc byte[8]; @@ -458,4 +544,11 @@ public sealed class ChatChannelService private static string Describe(ChannelKey key) => $"{key.Type}:{key.WorldId}:{key.CustomKey}"; + + public void Dispose() + { + _inactiveParticipantCleanupTimer.Dispose(); + } + + private readonly record struct InactiveParticipantEntry(ChatParticipantInfo Participant, DateTime ExpiresAt); } diff --git a/LightlessSyncServer/LightlessSyncServices/Discord/DiscordBot.cs b/LightlessSyncServer/LightlessSyncServices/Discord/DiscordBot.cs index de69b95..7db6a75 100644 --- a/LightlessSyncServer/LightlessSyncServices/Discord/DiscordBot.cs +++ b/LightlessSyncServer/LightlessSyncServices/Discord/DiscordBot.cs @@ -204,7 +204,7 @@ internal class DiscordBot : IHostedService var embed = await BuildChatReportEmbedAsync(dbContext, report, token).ConfigureAwait(false); var components = new ComponentBuilder() - .WithButton("Actioned", $"{ChatReportButtonPrefix}-resolve-{report.ReportId}", ButtonStyle.Danger) + .WithButton("Resolve", $"{ChatReportButtonPrefix}-resolve-{report.ReportId}", ButtonStyle.Danger) .WithButton("Dismiss", $"{ChatReportButtonPrefix}-dismiss-{report.ReportId}", ButtonStyle.Secondary) .WithButton("Ban From Chat", $"{ChatReportButtonPrefix}-banchat-{report.ReportId}", ButtonStyle.Danger); @@ -395,7 +395,7 @@ internal class DiscordBot : IHostedService switch (action) { case "resolve": - resolutionLabel = "Actioned"; + resolutionLabel = "Resolved"; break; case "dismiss": resolutionLabel = "Dismissed"; @@ -431,7 +431,7 @@ internal class DiscordBot : IHostedService string responseText = action switch { - "resolve" => "actioned", + "resolve" => "resolved", "dismiss" => "dismissed", "banchat" => "chat access revoked", _ => "processed" @@ -473,7 +473,7 @@ internal class DiscordBot : IHostedService embedBuilder.Fields.RemoveAll(f => string.Equals(f.Name, "Resolution", StringComparison.OrdinalIgnoreCase)); var resolutionText = action switch { - "resolve" => "Actioned", + "resolve" => "Resolved", "dismiss" => "Dismissed", "banchat" => "Chat access revoked", _ => "Processed" -- 2.49.1 From 989d07960178d34a96f3672cb99bbaf136036587 Mon Sep 17 00:00:00 2001 From: azyges <229218900+azyges@users.noreply.github.com> Date: Sun, 14 Dec 2025 15:55:32 +0900 Subject: [PATCH 2/4] chat zone config, add more default zones --- .../Configuration/ChatZoneOverridesOptions.cs | 16 ++ .../Models/ChatZoneDefinitions.cs | 155 +++++++++++++++++- .../Services/ChatChannelService.cs | 126 +++++++++++++- .../LightlessSyncServer/Startup.cs | 1 + .../LightlessSyncServer/appsettings.json | 3 + 5 files changed, 297 insertions(+), 4 deletions(-) create mode 100644 LightlessSyncServer/LightlessSyncServer/Configuration/ChatZoneOverridesOptions.cs diff --git a/LightlessSyncServer/LightlessSyncServer/Configuration/ChatZoneOverridesOptions.cs b/LightlessSyncServer/LightlessSyncServer/Configuration/ChatZoneOverridesOptions.cs new file mode 100644 index 0000000..8a531c0 --- /dev/null +++ b/LightlessSyncServer/LightlessSyncServer/Configuration/ChatZoneOverridesOptions.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; + +namespace LightlessSyncServer.Configuration; + +public sealed class ChatZoneOverridesOptions +{ + public List? Zones { get; set; } +} + +public sealed class ChatZoneOverride +{ + public string Key { get; set; } = string.Empty; + public string? DisplayName { get; set; } + public List? TerritoryNames { get; set; } + public List? TerritoryIds { get; set; } +} diff --git a/LightlessSyncServer/LightlessSyncServer/Models/ChatZoneDefinitions.cs b/LightlessSyncServer/LightlessSyncServer/Models/ChatZoneDefinitions.cs index ddc12f5..4d51848 100644 --- a/LightlessSyncServer/LightlessSyncServer/Models/ChatZoneDefinitions.cs +++ b/LightlessSyncServer/LightlessSyncServer/Models/ChatZoneDefinitions.cs @@ -61,7 +61,160 @@ internal static class ChatZoneDefinitions }, TerritoryIds: TerritoryRegistry.GetIds( "Ul'dah - Steps of Nald", - "Ul'dah - Steps of Thal")) + "Ul'dah - Steps of Thal")), + new ZoneChannelDefinition( + Key: "ishgard", + DisplayName: "Ishgard", + Descriptor: new ChatChannelDescriptor + { + Type = ChatChannelType.Zone, + WorldId = 0, + ZoneId = 0, + CustomKey = "ishgard" + }, + TerritoryNames: new[] + { + "Foundation", + "The Pillars" + }, + TerritoryIds: TerritoryRegistry.GetIds( + "Foundation", + "The Pillars")), + new ZoneChannelDefinition( + Key: "kugane", + DisplayName: "Kugane", + Descriptor: new ChatChannelDescriptor + { + Type = ChatChannelType.Zone, + WorldId = 0, + ZoneId = 0, + CustomKey = "kugane" + }, + TerritoryNames: new[] + { + "Kugane" + }, + TerritoryIds: TerritoryRegistry.GetIds("Kugane")), + new ZoneChannelDefinition( + Key: "crystarium", + DisplayName: "The Crystarium", + Descriptor: new ChatChannelDescriptor + { + Type = ChatChannelType.Zone, + WorldId = 0, + ZoneId = 0, + CustomKey = "crystarium" + }, + TerritoryNames: new[] + { + "The Crystarium" + }, + TerritoryIds: TerritoryRegistry.GetIds("The Crystarium")), + new ZoneChannelDefinition( + Key: "oldsharlayan", + DisplayName: "Old Sharlayan", + Descriptor: new ChatChannelDescriptor + { + Type = ChatChannelType.Zone, + WorldId = 0, + ZoneId = 0, + CustomKey = "oldsharlayan" + }, + TerritoryNames: new[] + { + "Old Sharlayan" + }, + TerritoryIds: TerritoryRegistry.GetIds("Old Sharlayan")), + new ZoneChannelDefinition( + Key: "tuliyollal", + DisplayName: "Tuliyollal", + Descriptor: new ChatChannelDescriptor + { + Type = ChatChannelType.Zone, + WorldId = 0, + ZoneId = 0, + CustomKey = "tuliyollal" + }, + TerritoryNames: new[] + { + "Tuliyollal" + }, + TerritoryIds: TerritoryRegistry.GetIds("Tuliyollal")), + new ZoneChannelDefinition( + Key: "eulmore", + DisplayName: "Eulmore", + Descriptor: new ChatChannelDescriptor + { + Type = ChatChannelType.Zone, + WorldId = 0, + ZoneId = 0, + CustomKey = "eulmore" + }, + TerritoryNames: new[] + { + "Eulmore" + }, + TerritoryIds: TerritoryRegistry.GetIds("Eulmore")), + new ZoneChannelDefinition( + Key: "idyllshire", + DisplayName: "Idyllshire", + Descriptor: new ChatChannelDescriptor + { + Type = ChatChannelType.Zone, + WorldId = 0, + ZoneId = 0, + CustomKey = "idyllshire" + }, + TerritoryNames: new[] + { + "Idyllshire" + }, + TerritoryIds: TerritoryRegistry.GetIds("Idyllshire")), + new ZoneChannelDefinition( + Key: "rhalgrsreach", + DisplayName: "Rhalgr's Reach", + Descriptor: new ChatChannelDescriptor + { + Type = ChatChannelType.Zone, + WorldId = 0, + ZoneId = 0, + CustomKey = "rhalgrsreach" + }, + TerritoryNames: new[] + { + "Rhalgr's Reach" + }, + TerritoryIds: TerritoryRegistry.GetIds("Rhalgr's Reach")), + new ZoneChannelDefinition( + Key: "radzathan", + DisplayName: "Radz-at-Han", + Descriptor: new ChatChannelDescriptor + { + Type = ChatChannelType.Zone, + WorldId = 0, + ZoneId = 0, + CustomKey = "radzathan" + }, + TerritoryNames: new[] + { + "Radz-at-Han" + }, + TerritoryIds: TerritoryRegistry.GetIds("Radz-at-Han")), + new ZoneChannelDefinition( + Key: "solutionnine", + DisplayName: "Solution Nine", + Descriptor: new ChatChannelDescriptor + { + Type = ChatChannelType.Zone, + WorldId = 0, + ZoneId = 0, + CustomKey = "solutionnine" + }, + TerritoryNames: new[] + { + "Solution Nine" + }, + TerritoryIds: TerritoryRegistry.GetIds("Solution Nine")) }; } diff --git a/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs b/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs index fb9746f..9d4990b 100644 --- a/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs +++ b/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs @@ -1,7 +1,9 @@ using System.Security.Cryptography; using LightlessSync.API.Data; using LightlessSync.API.Dto.Chat; +using LightlessSyncServer.Configuration; using LightlessSyncServer.Models; +using Microsoft.Extensions.Options; namespace LightlessSyncServer.Services; @@ -21,14 +23,132 @@ public sealed class ChatChannelService : IDisposable private static readonly TimeSpan InactiveParticipantCleanupInterval = TimeSpan.FromMinutes(1); private readonly Timer _inactiveParticipantCleanupTimer; - public ChatChannelService(ILogger logger) + public ChatChannelService(ILogger logger, IOptions? zoneOverrides = null) { _logger = logger; - _zoneDefinitions = ChatZoneDefinitions.Defaults - .ToDictionary(definition => definition.Key, StringComparer.OrdinalIgnoreCase); + _zoneDefinitions = BuildZoneDefinitions(zoneOverrides?.Value); _inactiveParticipantCleanupTimer = new Timer(_ => CleanupExpiredInactiveParticipants(), null, InactiveParticipantCleanupInterval, InactiveParticipantCleanupInterval); } + private Dictionary BuildZoneDefinitions(ChatZoneOverridesOptions? overrides) + { + var definitions = ChatZoneDefinitions.Defaults + .ToDictionary(definition => definition.Key, StringComparer.OrdinalIgnoreCase); + + if (overrides?.Zones is null || overrides.Zones.Count == 0) + { + return definitions; + } + + foreach (var entry in overrides.Zones) + { + if (entry is null) + { + continue; + } + + if (!TryCreateZoneDefinition(entry, out var definition)) + { + continue; + } + + definitions[definition.Key] = definition; + } + + return definitions; + } + + private bool TryCreateZoneDefinition(ChatZoneOverride entry, out ZoneChannelDefinition definition) + { + definition = default; + + var key = NormalizeZoneKey(entry.Key); + if (string.IsNullOrEmpty(key)) + { + _logger.LogWarning("Skipped chat zone override with missing key."); + return false; + } + + var territoryIds = new HashSet(); + if (entry.TerritoryIds is not null) + { + foreach (var candidate in entry.TerritoryIds) + { + if (candidate > 0) + { + territoryIds.Add(candidate); + } + } + } + + var territoryNames = new HashSet(StringComparer.OrdinalIgnoreCase); + if (entry.TerritoryNames is not null) + { + foreach (var name in entry.TerritoryNames) + { + if (string.IsNullOrWhiteSpace(name)) + continue; + + var trimmed = name.Trim(); + territoryNames.Add(trimmed); + if (TerritoryRegistry.TryGetIds(trimmed, out var ids)) + { + territoryIds.UnionWith(ids); + } + else + { + _logger.LogWarning("Chat zone override {Zone} references unknown territory '{Territory}'.", key, trimmed); + } + } + } + + if (territoryIds.Count == 0) + { + _logger.LogWarning("Skipped chat zone override for {Zone}: no territory IDs resolved.", key); + return false; + } + + if (territoryNames.Count == 0) + { + foreach (var territoryId in territoryIds) + { + if (TerritoryRegistry.ById.TryGetValue(territoryId, out var territory)) + { + territoryNames.Add(territory.Name); + } + } + } + + if (territoryNames.Count == 0) + { + territoryNames.Add("Territory"); + } + + var descriptor = new ChatChannelDescriptor + { + Type = ChatChannelType.Zone, + WorldId = 0, + ZoneId = 0, + CustomKey = key + }; + + var displayName = string.IsNullOrWhiteSpace(entry.DisplayName) + ? key + : entry.DisplayName.Trim(); + + definition = new ZoneChannelDefinition( + key, + displayName, + descriptor, + territoryNames.ToArray(), + territoryIds); + + return true; + } + + private static string NormalizeZoneKey(string? value) => + string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim(); + public IReadOnlyList GetZoneChannelInfos() => _zoneDefinitions.Values .Select(definition => new ZoneChatChannelInfoDto( diff --git a/LightlessSyncServer/LightlessSyncServer/Startup.cs b/LightlessSyncServer/LightlessSyncServer/Startup.cs index 8f043da..e16e24b 100644 --- a/LightlessSyncServer/LightlessSyncServer/Startup.cs +++ b/LightlessSyncServer/LightlessSyncServer/Startup.cs @@ -93,6 +93,7 @@ public class Startup services.Configure(Configuration.GetRequiredSection("LightlessSync")); services.Configure(Configuration.GetRequiredSection("LightlessSync")); services.Configure(Configuration.GetSection("Broadcast")); + services.Configure(Configuration.GetSection("ChatZoneOverrides")); services.AddSingleton(); services.AddSingleton(); diff --git a/LightlessSyncServer/LightlessSyncServer/appsettings.json b/LightlessSyncServer/LightlessSyncServer/appsettings.json index d3983ef..cf1ce10 100644 --- a/LightlessSyncServer/LightlessSyncServer/appsettings.json +++ b/LightlessSyncServer/LightlessSyncServer/appsettings.json @@ -41,6 +41,9 @@ "PairRequestRateLimit": 5, "PairRequestRateWindow": 60 }, + "ChatZoneOverrides": { + "Zones": [] + }, "AllowedHosts": "*", "Kestrel": { "Endpoints": { -- 2.49.1 From 67fe2a1f0f114e571ed6a055c05f7d5dc327fe9b Mon Sep 17 00:00:00 2001 From: azyges <229218900+azyges@users.noreply.github.com> Date: Wed, 17 Dec 2025 03:46:23 +0900 Subject: [PATCH 3/4] just chat improvements :sludge: --- LightlessAPI | 2 +- .../Hubs/LightlessHub.Chat.cs | 359 ++++++++++-------- .../Hubs/LightlessHub.User.cs | 8 +- .../LightlessSyncServer/Hubs/LightlessHub.cs | 2 +- .../LightlessSyncServer/Models/ChatModels.cs | 5 +- .../Models/ChatZoneDefinitions.cs | 157 +------- .../Services/ChatChannelService.cs | 273 ++++++++----- .../Utils/ChatMessageFilter.cs | 26 ++ .../Utils/LightlessHubLogger.cs | 10 + .../Models/ReportedChatMessage.cs | 3 - 10 files changed, 434 insertions(+), 411 deletions(-) create mode 100644 LightlessSyncServer/LightlessSyncServer/Utils/ChatMessageFilter.cs diff --git a/LightlessAPI b/LightlessAPI index dfb0594..6b54352 160000 --- a/LightlessAPI +++ b/LightlessAPI @@ -1 +1 @@ -Subproject commit dfb0594a5be49994cda6d95aa0d995bd93cdfbc0 +Subproject commit 6b543529aa2dd660ed397eb45ed8b2936664e8ef diff --git a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs index 12ed62d..1e21a90 100644 --- a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs +++ b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs @@ -2,14 +2,13 @@ using System.Collections.Concurrent; 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; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; - namespace LightlessSyncServer.Hubs; public partial class LightlessHub @@ -81,14 +80,18 @@ public partial class LightlessHub if (userRecord.ChatBanned) { - _chatChannelService.RemovePresence(UserUID); + TryInvokeChatService(() => _chatChannelService.RemovePresence(UserUID), "clearing presence for banned user"); await NotifyChatBanAsync(UserUID).ConfigureAwait(false); return; } if (!presence.IsActive) { - _chatChannelService.RemovePresence(UserUID, channel); + if (!TryInvokeChatService(() => _chatChannelService.RemovePresence(UserUID, channel), "removing chat presence", channel)) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "We couldn't update your chat presence. Please try again.").ConfigureAwait(false); + } + return; } @@ -122,14 +125,22 @@ public partial class LightlessHub } } - _chatChannelService.UpdateZonePresence( - UserUID, - definition, - channel.WorldId, - presence.TerritoryId, - hashedCid, - isLightfinder, - isActive: true); + if (!TryInvokeChatService( + () => _chatChannelService.UpdateZonePresence( + UserUID, + definition, + channel.WorldId, + presence.TerritoryId, + hashedCid, + isLightfinder, + isActive: true), + "updating zone chat presence", + channel)) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Zone chat is temporarily unavailable. Please try again.").ConfigureAwait(false); + return; + } + break; case ChatChannelType.Group: @@ -164,13 +175,21 @@ public partial class LightlessHub var displayName = string.IsNullOrWhiteSpace(group.Alias) ? group.GID : group.Alias; - _chatChannelService.UpdateGroupPresence( - UserUID, - group.GID, - displayName, - userData, - IsValidHashedCid(UserCharaIdent) ? UserCharaIdent : null, - isActive: true); + if (!TryInvokeChatService( + () => _chatChannelService.UpdateGroupPresence( + UserUID, + group.GID, + displayName, + userData, + IsValidHashedCid(UserCharaIdent) ? UserCharaIdent : null, + isActive: true), + "updating group chat presence", + channel)) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Syncshell chat is temporarily unavailable. Please try again.").ConfigureAwait(false); + return; + } + break; default: @@ -210,119 +229,101 @@ public partial class LightlessHub sanitizedMessage = sanitizedMessage[..MaxChatMessageLength]; } - var recipients = _chatChannelService.GetMembers(presence.Channel); - var recipientsList = recipients.ToList(); - if (recipientsList.Count == 0) + if (channel.Type == ChatChannelType.Zone && + !ChatMessageFilter.TryValidate(sanitizedMessage, out var rejectionReason)) { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, rejectionReason).ConfigureAwait(false); 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) + try { - bannedSet = new HashSet(bannedRecipients, StringComparer.Ordinal); - foreach (var bannedUid in bannedSet) + var recipients = _chatChannelService.GetMembers(presence.Channel); + var recipientsList = recipients.ToList(); + if (recipientsList.Count == 0) { - _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; + return; } - if (_userConnections.TryGetValue(uid, out var connectionId)) + 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) { - var includeSensitive = await AllowsLightfinderDetailsAsync(presence.Channel, uid).ConfigureAwait(false); - if (deliveryTargets.TryGetValue(connectionId, out var existing)) + bannedSet = new HashSet(bannedRecipients, StringComparer.Ordinal); + foreach (var bannedUid in bannedSet) { - deliveryTargets[connectionId] = (existing.Uid, existing.IncludeSensitive || includeSensitive); + _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)) + { + if (_chatChannelService.IsTokenMuted(uid, presence.Channel, presence.Participant.Token)) + { + continue; + } + + var includeSensitive = await AllowsLightfinderDetailsAsync(presence.Channel, uid).ConfigureAwait(false); + if (deliveryTargets.TryGetValue(connectionId, out var existing)) + { + deliveryTargets[connectionId] = (existing.Uid, existing.IncludeSensitive || includeSensitive); + } + else + { + deliveryTargets[connectionId] = (uid, includeSensitive); + } } else { - deliveryTargets[connectionId] = (uid, includeSensitive); + _chatChannelService.RemovePresence(uid); } } - else + + if (deliveryTargets.Count == 0) { - _chatChannelService.RemovePresence(uid); + return; } - } - if (deliveryTargets.Count == 0) + 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); + } + catch (Exception ex) { - return; + _logger.LogError(ex, "Failed to deliver chat message for {User} in {Channel}", UserUID, DescribeChannel(presence.Channel)); + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Something went wrong while sending your message. Please try again.").ConfigureAwait(false); } - - 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")] @@ -436,7 +437,6 @@ public partial class LightlessHub MessageId = messageEntry.MessageId, MessageSentAtUtc = messageEntry.SentAtUtc, MessageContent = messageEntry.Message, - SenderToken = messageEntry.SenderToken, SenderHashedCid = messageEntry.SenderHashedCid, SenderDisplayName = messageEntry.SenderUser?.AliasOrUID, SenderWasLightfinder = messageEntry.SenderIsLightfinder, @@ -451,6 +451,91 @@ public partial class LightlessHub await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, "Thank you. Your report has been queued for moderator review.").ConfigureAwait(false); } + [Authorize(Policy = "Identified")] + public async Task SetChatParticipantMute(ChatParticipantMuteRequestDto request) + { + var channel = request.Channel.WithNormalizedCustomKey(); + + if (!_chatChannelService.TryGetPresence(UserUID, channel, out _)) + { + throw new HubException("Join the chat channel before updating mutes."); + } + + if (string.IsNullOrWhiteSpace(request.Token)) + { + throw new HubException("Invalid participant."); + } + + if (!_chatChannelService.TryGetActiveParticipant(channel, request.Token, out var participant)) + { + throw new HubException("Unable to locate that participant in this channel."); + } + + if (string.Equals(participant.UserUid, UserUID, StringComparison.Ordinal)) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You cannot mute yourself.").ConfigureAwait(false); + return; + } + + ChatMuteUpdateResult result; + try + { + result = _chatChannelService.SetMutedParticipant(UserUID, channel, participant, request.Mute); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update mute for {User} in {Channel}", UserUID, DescribeChannel(channel)); + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Unable to update mute settings right now. Please try again.").ConfigureAwait(false); + return; + } + + if (result == ChatMuteUpdateResult.ChannelLimitReached) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, $"You can mute at most {ChatChannelService.MaxMutedParticipantsPerChannel} participants per channel. Unmute someone before adding another mute.").ConfigureAwait(false); + return; + } + + if (result != ChatMuteUpdateResult.Changed) + { + return; + } + + if (request.Mute) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, "You will no longer receive this participant's messages in the current channel.").ConfigureAwait(false); + } + else + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, "You will receive this participant's messages again.").ConfigureAwait(false); + } + } + + private static string DescribeChannel(ChatChannelDescriptor descriptor) => + $"{descriptor.Type}:{descriptor.WorldId}:{descriptor.CustomKey}"; + + private bool TryInvokeChatService(Action action, string operationDescription, ChatChannelDescriptor? descriptor = null, string? targetUserUid = null) + { + try + { + action(); + return true; + } + catch (Exception ex) + { + var logUser = targetUserUid ?? UserUID; + if (descriptor is ChatChannelDescriptor described) + { + _logger.LogError(ex, "Chat service failed while {Operation} for {User} in {Channel}", operationDescription, logUser, DescribeChannel(described)); + } + else + { + _logger.LogError(ex, "Chat service failed while {Operation} for {User}", operationDescription, logUser); + } + + return false; + } + } + private ChatSenderDescriptor BuildSenderDescriptor(ChatChannelDescriptor descriptor, ChatParticipantInfo participant, bool includeSensitiveInfo = false) { var kind = descriptor.Type == ChatChannelType.Group @@ -477,7 +562,7 @@ public partial class LightlessHub ? participant.HashedCid : null; - var canResolveProfile = kind == ChatSenderKind.IdentifiedUser || participant.IsLightfinder; + var canResolveProfile = includeSensitiveInfo && (kind == ChatSenderKind.IdentifiedUser || participant.IsLightfinder); return new ChatSenderDescriptor( kind, @@ -488,44 +573,6 @@ public partial class LightlessHub 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) @@ -560,7 +607,11 @@ public partial class LightlessHub var (entry, expiry) = await TryGetBroadcastEntryAsync(presence.Participant.HashedCid!).ConfigureAwait(false); if (!IsActiveBroadcastForUser(entry, expiry, userUid)) { - _chatChannelService.RefreshLightfinderState(userUid, null, isLightfinder: false); + TryInvokeChatService( + () => _chatChannelService.RefreshLightfinderState(userUid, null, isLightfinder: false), + "refreshing lightfinder state", + descriptor, + userUid); return false; } @@ -580,7 +631,7 @@ public partial class LightlessHub if (!isBanned) return false; - _chatChannelService.RemovePresence(userUid); + TryInvokeChatService(() => _chatChannelService.RemovePresence(userUid), "clearing presence for chat-banned user", targetUserUid: userUid); await NotifyChatBanAsync(userUid).ConfigureAwait(false); return true; } diff --git a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.User.cs b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.User.cs index bf1981f..d41211a 100644 --- a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.User.cs +++ b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.User.cs @@ -464,7 +464,9 @@ public partial class LightlessHub await db.StringSetAsync(broadcastKey, json, _broadcastConfiguration.BroadcastEntryTtl).ConfigureAwait(false); await db.StringSetAsync(ownershipKey, hashedCid, _broadcastConfiguration.BroadcastEntryTtl).ConfigureAwait(false); _logger.LogCallInfo(LightlessHubLogger.Args("broadcast enabled", hashedCid, "GID", gid)); - _chatChannelService.RefreshLightfinderState(UserUID, hashedCid, isLightfinder: true); + TryInvokeChatService( + () => _chatChannelService.RefreshLightfinderState(UserUID, hashedCid, isLightfinder: true), + "refreshing lightfinder state (enable)"); } else { @@ -505,7 +507,9 @@ public partial class LightlessHub } _logger.LogCallInfo(LightlessHubLogger.Args("broadcast disabled", hashedCid, "GID", entry.GID)); - _chatChannelService.RefreshLightfinderState(UserUID, null, isLightfinder: false); + TryInvokeChatService( + () => _chatChannelService.RefreshLightfinderState(UserUID, null, isLightfinder: false), + "refreshing lightfinder state (disable)"); } } diff --git a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.cs b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.cs index ccfae97..99963d6 100644 --- a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.cs +++ b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.cs @@ -227,7 +227,7 @@ public partial class LightlessHub : Hub, ILightlessHub catch { } finally { - _chatChannelService.RemovePresence(UserUID); + TryInvokeChatService(() => _chatChannelService.RemovePresence(UserUID), "removing chat presence on disconnect"); _userConnections.Remove(UserUID, out _); } } diff --git a/LightlessSyncServer/LightlessSyncServer/Models/ChatModels.cs b/LightlessSyncServer/LightlessSyncServer/Models/ChatModels.cs index 39214dc..7d78c21 100644 --- a/LightlessSyncServer/LightlessSyncServer/Models/ChatModels.cs +++ b/LightlessSyncServer/LightlessSyncServer/Models/ChatModels.cs @@ -1,6 +1,4 @@ -using System; -using System.Collections.Generic; -using LightlessSync.API.Data; +using LightlessSync.API.Data; using LightlessSync.API.Dto.Chat; namespace LightlessSyncServer.Models; @@ -32,7 +30,6 @@ public readonly record struct ChatMessageLogEntry( string MessageId, ChatChannelDescriptor Channel, DateTime SentAtUtc, - string SenderToken, string SenderUserUid, UserData? SenderUser, bool SenderIsLightfinder, diff --git a/LightlessSyncServer/LightlessSyncServer/Models/ChatZoneDefinitions.cs b/LightlessSyncServer/LightlessSyncServer/Models/ChatZoneDefinitions.cs index 4d51848..5157c23 100644 --- a/LightlessSyncServer/LightlessSyncServer/Models/ChatZoneDefinitions.cs +++ b/LightlessSyncServer/LightlessSyncServer/Models/ChatZoneDefinitions.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using LightlessSync.API.Dto.Chat; +using LightlessSync.API.Dto.Chat; namespace LightlessSyncServer.Models; @@ -62,159 +61,5 @@ internal static class ChatZoneDefinitions TerritoryIds: TerritoryRegistry.GetIds( "Ul'dah - Steps of Nald", "Ul'dah - Steps of Thal")), - new ZoneChannelDefinition( - Key: "ishgard", - DisplayName: "Ishgard", - Descriptor: new ChatChannelDescriptor - { - Type = ChatChannelType.Zone, - WorldId = 0, - ZoneId = 0, - CustomKey = "ishgard" - }, - TerritoryNames: new[] - { - "Foundation", - "The Pillars" - }, - TerritoryIds: TerritoryRegistry.GetIds( - "Foundation", - "The Pillars")), - new ZoneChannelDefinition( - Key: "kugane", - DisplayName: "Kugane", - Descriptor: new ChatChannelDescriptor - { - Type = ChatChannelType.Zone, - WorldId = 0, - ZoneId = 0, - CustomKey = "kugane" - }, - TerritoryNames: new[] - { - "Kugane" - }, - TerritoryIds: TerritoryRegistry.GetIds("Kugane")), - new ZoneChannelDefinition( - Key: "crystarium", - DisplayName: "The Crystarium", - Descriptor: new ChatChannelDescriptor - { - Type = ChatChannelType.Zone, - WorldId = 0, - ZoneId = 0, - CustomKey = "crystarium" - }, - TerritoryNames: new[] - { - "The Crystarium" - }, - TerritoryIds: TerritoryRegistry.GetIds("The Crystarium")), - new ZoneChannelDefinition( - Key: "oldsharlayan", - DisplayName: "Old Sharlayan", - Descriptor: new ChatChannelDescriptor - { - Type = ChatChannelType.Zone, - WorldId = 0, - ZoneId = 0, - CustomKey = "oldsharlayan" - }, - TerritoryNames: new[] - { - "Old Sharlayan" - }, - TerritoryIds: TerritoryRegistry.GetIds("Old Sharlayan")), - new ZoneChannelDefinition( - Key: "tuliyollal", - DisplayName: "Tuliyollal", - Descriptor: new ChatChannelDescriptor - { - Type = ChatChannelType.Zone, - WorldId = 0, - ZoneId = 0, - CustomKey = "tuliyollal" - }, - TerritoryNames: new[] - { - "Tuliyollal" - }, - TerritoryIds: TerritoryRegistry.GetIds("Tuliyollal")), - new ZoneChannelDefinition( - Key: "eulmore", - DisplayName: "Eulmore", - Descriptor: new ChatChannelDescriptor - { - Type = ChatChannelType.Zone, - WorldId = 0, - ZoneId = 0, - CustomKey = "eulmore" - }, - TerritoryNames: new[] - { - "Eulmore" - }, - TerritoryIds: TerritoryRegistry.GetIds("Eulmore")), - new ZoneChannelDefinition( - Key: "idyllshire", - DisplayName: "Idyllshire", - Descriptor: new ChatChannelDescriptor - { - Type = ChatChannelType.Zone, - WorldId = 0, - ZoneId = 0, - CustomKey = "idyllshire" - }, - TerritoryNames: new[] - { - "Idyllshire" - }, - TerritoryIds: TerritoryRegistry.GetIds("Idyllshire")), - new ZoneChannelDefinition( - Key: "rhalgrsreach", - DisplayName: "Rhalgr's Reach", - Descriptor: new ChatChannelDescriptor - { - Type = ChatChannelType.Zone, - WorldId = 0, - ZoneId = 0, - CustomKey = "rhalgrsreach" - }, - TerritoryNames: new[] - { - "Rhalgr's Reach" - }, - TerritoryIds: TerritoryRegistry.GetIds("Rhalgr's Reach")), - new ZoneChannelDefinition( - Key: "radzathan", - DisplayName: "Radz-at-Han", - Descriptor: new ChatChannelDescriptor - { - Type = ChatChannelType.Zone, - WorldId = 0, - ZoneId = 0, - CustomKey = "radzathan" - }, - TerritoryNames: new[] - { - "Radz-at-Han" - }, - TerritoryIds: TerritoryRegistry.GetIds("Radz-at-Han")), - new ZoneChannelDefinition( - Key: "solutionnine", - DisplayName: "Solution Nine", - Descriptor: new ChatChannelDescriptor - { - Type = ChatChannelType.Zone, - WorldId = 0, - ZoneId = 0, - CustomKey = "solutionnine" - }, - TerritoryNames: new[] - { - "Solution Nine" - }, - TerritoryIds: TerritoryRegistry.GetIds("Solution Nine")) }; - } diff --git a/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs b/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs index 9d4990b..830f03d 100644 --- a/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs +++ b/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs @@ -14,20 +14,18 @@ public sealed class ChatChannelService : IDisposable private readonly Dictionary> _membersByChannel = new(); private readonly Dictionary> _presenceByUser = new(StringComparer.Ordinal); private readonly Dictionary> _participantsByChannel = new(); - private readonly Dictionary> _inactiveParticipantsByChannel = new(); private readonly Dictionary> _messagesByChannel = new(); private readonly Dictionary Node)> _messageIndex = new(StringComparer.Ordinal); + private readonly Dictionary>> _mutedTokensByUser = new(StringComparer.Ordinal); + private readonly Dictionary>> _mutedUidsByUser = new(StringComparer.Ordinal); private readonly object _syncRoot = new(); private const int MaxMessagesPerChannel = 200; - private static readonly TimeSpan InactiveParticipantRetention = TimeSpan.FromMinutes(15); - private static readonly TimeSpan InactiveParticipantCleanupInterval = TimeSpan.FromMinutes(1); - private readonly Timer _inactiveParticipantCleanupTimer; + internal const int MaxMutedParticipantsPerChannel = 8; public ChatChannelService(ILogger logger, IOptions? zoneOverrides = null) { _logger = logger; _zoneDefinitions = BuildZoneDefinitions(zoneOverrides?.Value); - _inactiveParticipantCleanupTimer = new Timer(_ => CleanupExpiredInactiveParticipants(), null, InactiveParticipantCleanupInterval, InactiveParticipantCleanupInterval); } private Dictionary BuildZoneDefinitions(ChatZoneOverridesOptions? overrides) @@ -275,7 +273,6 @@ public sealed class ChatChannelService : IDisposable messageId, channel, sentAtUtc, - participant.Token, participant.UserUid, participant.User, participant.IsLightfinder, @@ -381,39 +378,6 @@ public sealed class ChatChannelService : IDisposable } } - 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; - } - - if (_inactiveParticipantsByChannel.TryGetValue(key, out var inactive) && - inactive.TryGetValue(token, out var inactiveEntry)) - { - if (inactiveEntry.ExpiresAt > DateTime.UtcNow) - { - participant = inactiveEntry.Participant; - return true; - } - - inactive.Remove(token); - if (inactive.Count == 0) - { - _inactiveParticipantsByChannel.Remove(key); - } - } - } - - participant = default; - return false; - } - public void RefreshLightfinderState(string userUid, string? hashedCid, bool isLightfinder) { ArgumentException.ThrowIfNullOrEmpty(userUid); @@ -549,7 +513,8 @@ public sealed class ChatChannelService : IDisposable } participantsByToken[token] = finalParticipant; - RemoveInactiveParticipantLocked(key, token); + + ApplyUIDMuteIfPresent(normalizedDescriptor, finalParticipant); _logger.LogDebug("Chat presence updated for {User} in {Channel}", userUid, Describe(key)); return entryToStore; @@ -584,74 +549,110 @@ public sealed class ChatChannelService : IDisposable } } - StoreInactiveParticipantLocked(key, existing.Participant); + ClearMutesForChannel(userUid, key); return true; } - private void StoreInactiveParticipantLocked(ChannelKey key, ChatParticipantInfo participant) + internal bool TryGetActiveParticipant(ChatChannelDescriptor channel, string token, out ChatParticipantInfo participant) { - if (string.IsNullOrEmpty(participant.Token)) - { - return; - } + var key = ChannelKey.FromDescriptor(channel.WithNormalizedCustomKey()); - if (!_inactiveParticipantsByChannel.TryGetValue(key, out var inactive)) - { - inactive = new Dictionary(StringComparer.Ordinal); - _inactiveParticipantsByChannel[key] = inactive; - } - - inactive[participant.Token] = new InactiveParticipantEntry(participant, DateTime.UtcNow + InactiveParticipantRetention); - } - - private void RemoveInactiveParticipantLocked(ChannelKey key, string token) - { - if (_inactiveParticipantsByChannel.TryGetValue(key, out var inactive) && - inactive.Remove(token) && - inactive.Count == 0) - { - _inactiveParticipantsByChannel.Remove(key); - } - } - - private void CleanupExpiredInactiveParticipants() - { lock (_syncRoot) { - if (_inactiveParticipantsByChannel.Count == 0) + if (_participantsByChannel.TryGetValue(key, out var participants) && + participants.TryGetValue(token, out participant)) { - return; + return true; + } + } + + participant = default; + return false; + } + + internal bool IsTokenMuted(string userUid, ChatChannelDescriptor channel, string token) + { + var key = ChannelKey.FromDescriptor(channel.WithNormalizedCustomKey()); + + lock (_syncRoot) + { + if (!_mutedTokensByUser.TryGetValue(userUid, out var channels) || + !channels.TryGetValue(key, out var tokens)) + { + return false; } - var now = DateTime.UtcNow; - var channelsToRemove = new List(); - - foreach (var (key, inactive) in _inactiveParticipantsByChannel) + if (tokens.Contains(token)) { - var tokensToRemove = new List(); - foreach (var (token, entry) in inactive) + return true; + } + + if (_participantsByChannel.TryGetValue(key, out var participants) && + participants.TryGetValue(token, out var participant)) + { + return IsUIDMutedLocked(userUid, key, participant.UserUid); + } + + return false; + } + } + + public ChatMuteUpdateResult SetMutedParticipant(string userUid, ChatChannelDescriptor channel, ChatParticipantInfo participant, bool mute) + { + ArgumentException.ThrowIfNullOrEmpty(userUid); + ArgumentException.ThrowIfNullOrEmpty(participant.Token); + ArgumentException.ThrowIfNullOrEmpty(participant.UserUid); + + var key = ChannelKey.FromDescriptor(channel.WithNormalizedCustomKey()); + + lock (_syncRoot) + { + if (!_mutedTokensByUser.TryGetValue(userUid, out var channels)) + { + if (!mute) { - if (entry.ExpiresAt <= now) - { - tokensToRemove.Add(token); - } + return ChatMuteUpdateResult.NoChange; } - foreach (var token in tokensToRemove) + channels = new Dictionary>(); + _mutedTokensByUser[userUid] = channels; + } + + if (!channels.TryGetValue(key, out var tokens)) + { + if (!mute) { - inactive.Remove(token); + return ChatMuteUpdateResult.NoChange; } - if (inactive.Count == 0) + tokens = new HashSet(StringComparer.Ordinal); + channels[key] = tokens; + } + + if (mute) + { + if (!tokens.Contains(participant.Token) && tokens.Count >= MaxMutedParticipantsPerChannel) { - channelsToRemove.Add(key); + return ChatMuteUpdateResult.ChannelLimitReached; + } + + var added = tokens.Add(participant.Token); + EnsureUIDMuteLocked(userUid, key, participant.UserUid); + return added ? ChatMuteUpdateResult.Changed : ChatMuteUpdateResult.NoChange; + } + + var removed = tokens.Remove(participant.Token); + if (tokens.Count == 0) + { + channels.Remove(key); + if (channels.Count == 0) + { + _mutedTokensByUser.Remove(userUid); } } - foreach (var channel in channelsToRemove) - { - _inactiveParticipantsByChannel.Remove(channel); - } + RemoveUIDMuteLocked(userUid, key, participant.UserUid); + return removed ? ChatMuteUpdateResult.Changed : ChatMuteUpdateResult.NoChange; } } @@ -665,10 +666,102 @@ public sealed class ChatChannelService : IDisposable private static string Describe(ChannelKey key) => $"{key.Type}:{key.WorldId}:{key.CustomKey}"; - public void Dispose() + private void ClearMutesForChannel(string userUid, ChannelKey key) { - _inactiveParticipantCleanupTimer.Dispose(); + if (_mutedTokensByUser.TryGetValue(userUid, out var tokenChannels) && + tokenChannels.Remove(key) && + tokenChannels.Count == 0) + { + _mutedTokensByUser.Remove(userUid); + } + + if (_mutedUidsByUser.TryGetValue(userUid, out var uidChannels) && + uidChannels.Remove(key) && + uidChannels.Count == 0) + { + _mutedUidsByUser.Remove(userUid); + } } - private readonly record struct InactiveParticipantEntry(ChatParticipantInfo Participant, DateTime ExpiresAt); + private void ApplyUIDMuteIfPresent(ChatChannelDescriptor descriptor, ChatParticipantInfo participant) + { + var key = ChannelKey.FromDescriptor(descriptor); + foreach (var kvp in _mutedUidsByUser) + { + var muter = kvp.Key; + var channels = kvp.Value; + if (!channels.TryGetValue(key, out var mutedUids) || !mutedUids.Contains(participant.UserUid)) + { + continue; + } + + if (!_mutedTokensByUser.TryGetValue(muter, out var tokenChannels)) + { + tokenChannels = new Dictionary>(); + _mutedTokensByUser[muter] = tokenChannels; + } + + if (!tokenChannels.TryGetValue(key, out var tokens)) + { + tokens = new HashSet(StringComparer.Ordinal); + tokenChannels[key] = tokens; + } + + tokens.Add(participant.Token); + } + } + + private void EnsureUIDMuteLocked(string userUid, ChannelKey key, string targetUid) + { + if (!_mutedUidsByUser.TryGetValue(userUid, out var channels)) + { + channels = new Dictionary>(); + _mutedUidsByUser[userUid] = channels; + } + + if (!channels.TryGetValue(key, out var set)) + { + set = new HashSet(StringComparer.Ordinal); + channels[key] = set; + } + + set.Add(targetUid); + } + + private void RemoveUIDMuteLocked(string userUid, ChannelKey key, string targetUid) + { + if (!_mutedUidsByUser.TryGetValue(userUid, out var channels) || + !channels.TryGetValue(key, out var set)) + { + return; + } + + set.Remove(targetUid); + if (set.Count == 0) + { + channels.Remove(key); + if (channels.Count == 0) + { + _mutedUidsByUser.Remove(userUid); + } + } + } + + private bool IsUIDMutedLocked(string userUid, ChannelKey key, string targetUid) + { + return _mutedUidsByUser.TryGetValue(userUid, out var channels) && + channels.TryGetValue(key, out var set) && + set.Contains(targetUid); + } + + public void Dispose() + { + } +} + +public enum ChatMuteUpdateResult +{ + NoChange, + Changed, + ChannelLimitReached } diff --git a/LightlessSyncServer/LightlessSyncServer/Utils/ChatMessageFilter.cs b/LightlessSyncServer/LightlessSyncServer/Utils/ChatMessageFilter.cs new file mode 100644 index 0000000..c732894 --- /dev/null +++ b/LightlessSyncServer/LightlessSyncServer/Utils/ChatMessageFilter.cs @@ -0,0 +1,26 @@ +using System; +using System.Text.RegularExpressions; + +namespace LightlessSyncServer.Utils; + +internal static class ChatMessageFilter +{ + private static readonly Regex UrlRegex = new(@"\b(?:https?://|www\.)\S+", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + public static bool TryValidate(string? message, out string rejectionReason) + { + rejectionReason = string.Empty; + if (string.IsNullOrWhiteSpace(message)) + { + return true; + } + + if (UrlRegex.IsMatch(message)) + { + rejectionReason = "Links are not permitted in chat."; + return false; + } + + return true; + } +} diff --git a/LightlessSyncServer/LightlessSyncServer/Utils/LightlessHubLogger.cs b/LightlessSyncServer/LightlessSyncServer/Utils/LightlessHubLogger.cs index bb73bfa..fa4b498 100644 --- a/LightlessSyncServer/LightlessSyncServer/Utils/LightlessHubLogger.cs +++ b/LightlessSyncServer/LightlessSyncServer/Utils/LightlessHubLogger.cs @@ -30,4 +30,14 @@ public class LightlessHubLogger string formattedArgs = args != null && args.Length != 0 ? "|" + string.Join(":", args) : string.Empty; _logger.LogWarning("{uid}:{method}{args}", _hub.UserUID, methodName, formattedArgs); } + + public void LogError(Exception exception, string message, params object[] args) + { + _logger.LogError(exception, message, args); + } + + public void LogError(string message, params object[] args) + { + _logger.LogError(message, args); + } } diff --git a/LightlessSyncServer/LightlessSyncShared/Models/ReportedChatMessage.cs b/LightlessSyncServer/LightlessSyncShared/Models/ReportedChatMessage.cs index f979294..eb75a42 100644 --- a/LightlessSyncServer/LightlessSyncShared/Models/ReportedChatMessage.cs +++ b/LightlessSyncServer/LightlessSyncShared/Models/ReportedChatMessage.cs @@ -40,9 +40,6 @@ public class ReportedChatMessage [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; } -- 2.49.1 From 7f01cc3661e418b7571024b87b6cb4a65dcf9753 Mon Sep 17 00:00:00 2001 From: cake Date: Thu, 18 Dec 2025 22:19:19 +0100 Subject: [PATCH 4/4] bump api --- LightlessAPI | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LightlessAPI b/LightlessAPI index 6b54352..35f3390 160000 --- a/LightlessAPI +++ b/LightlessAPI @@ -1 +1 @@ -Subproject commit 6b543529aa2dd660ed397eb45ed8b2936664e8ef +Subproject commit 35f3390dda237aaa6b4514dcef96969c24028b7f -- 2.49.1