Merge pull request 'Chat adjustment for release' (#38) from chat-adjustments into master

Reviewed-on: #38
This commit was merged in pull request #38.
This commit is contained in:
2025-12-18 21:19:55 +00:00
14 changed files with 609 additions and 206 deletions

View File

@@ -0,0 +1,16 @@
using System.Collections.Generic;
namespace LightlessSyncServer.Configuration;
public sealed class ChatZoneOverridesOptions
{
public List<ChatZoneOverride>? Zones { get; set; }
}
public sealed class ChatZoneOverride
{
public string Key { get; set; } = string.Empty;
public string? DisplayName { get; set; }
public List<string>? TerritoryNames { get; set; }
public List<ushort>? TerritoryIds { get; set; }
}

View File

@@ -2,14 +2,13 @@ using System.Collections.Concurrent;
using System.Text.Json; using System.Text.Json;
using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Enum;
using LightlessSync.API.Dto.Chat; using LightlessSync.API.Dto.Chat;
using LightlessSync.API.Dto.User;
using LightlessSyncServer.Models; using LightlessSyncServer.Models;
using LightlessSyncServer.Services;
using LightlessSyncServer.Utils; using LightlessSyncServer.Utils;
using LightlessSyncShared.Models; using LightlessSyncShared.Models;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace LightlessSyncServer.Hubs; namespace LightlessSyncServer.Hubs;
public partial class LightlessHub public partial class LightlessHub
@@ -81,14 +80,18 @@ public partial class LightlessHub
if (userRecord.ChatBanned) if (userRecord.ChatBanned)
{ {
_chatChannelService.RemovePresence(UserUID); TryInvokeChatService(() => _chatChannelService.RemovePresence(UserUID), "clearing presence for banned user");
await NotifyChatBanAsync(UserUID).ConfigureAwait(false); await NotifyChatBanAsync(UserUID).ConfigureAwait(false);
return; return;
} }
if (!presence.IsActive) 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; return;
} }
@@ -122,14 +125,22 @@ public partial class LightlessHub
} }
} }
_chatChannelService.UpdateZonePresence( if (!TryInvokeChatService(
UserUID, () => _chatChannelService.UpdateZonePresence(
definition, UserUID,
channel.WorldId, definition,
presence.TerritoryId, channel.WorldId,
hashedCid, presence.TerritoryId,
isLightfinder, hashedCid,
isActive: true); 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; break;
case ChatChannelType.Group: case ChatChannelType.Group:
@@ -164,13 +175,21 @@ public partial class LightlessHub
var displayName = string.IsNullOrWhiteSpace(group.Alias) ? group.GID : group.Alias; var displayName = string.IsNullOrWhiteSpace(group.Alias) ? group.GID : group.Alias;
_chatChannelService.UpdateGroupPresence( if (!TryInvokeChatService(
UserUID, () => _chatChannelService.UpdateGroupPresence(
group.GID, UserUID,
displayName, group.GID,
userData, displayName,
IsValidHashedCid(UserCharaIdent) ? UserCharaIdent : null, userData,
isActive: true); 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; break;
default: default:
@@ -210,119 +229,101 @@ public partial class LightlessHub
sanitizedMessage = sanitizedMessage[..MaxChatMessageLength]; sanitizedMessage = sanitizedMessage[..MaxChatMessageLength];
} }
var recipients = _chatChannelService.GetMembers(presence.Channel); if (channel.Type == ChatChannelType.Zone &&
var recipientsList = recipients.ToList(); !ChatMessageFilter.TryValidate(sanitizedMessage, out var rejectionReason))
if (recipientsList.Count == 0)
{ {
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, rejectionReason).ConfigureAwait(false);
return; return;
} }
var bannedRecipients = recipientsList.Count == 0 try
? new List<string>()
: await DbContext.Users.AsNoTracking()
.Where(u => recipientsList.Contains(u.UID) && u.ChatBanned)
.Select(u => u.UID)
.ToListAsync(RequestAbortedToken)
.ConfigureAwait(false);
HashSet<string>? bannedSet = null;
if (bannedRecipients.Count > 0)
{ {
bannedSet = new HashSet<string>(bannedRecipients, StringComparer.Ordinal); var recipients = _chatChannelService.GetMembers(presence.Channel);
foreach (var bannedUid in bannedSet) var recipientsList = recipients.ToList();
if (recipientsList.Count == 0)
{ {
_chatChannelService.RemovePresence(bannedUid); return;
await NotifyChatBanAsync(bannedUid).ConfigureAwait(false);
}
}
var deliveryTargets = new Dictionary<string, (string Uid, bool IncludeSensitive)>(StringComparer.Ordinal);
foreach (var uid in recipientsList)
{
if (bannedSet != null && bannedSet.Contains(uid))
{
continue;
} }
if (_userConnections.TryGetValue(uid, out var connectionId)) var bannedRecipients = recipientsList.Count == 0
? new List<string>()
: await DbContext.Users.AsNoTracking()
.Where(u => recipientsList.Contains(u.UID) && u.ChatBanned)
.Select(u => u.UID)
.ToListAsync(RequestAbortedToken)
.ConfigureAwait(false);
HashSet<string>? bannedSet = null;
if (bannedRecipients.Count > 0)
{ {
var includeSensitive = await AllowsLightfinderDetailsAsync(presence.Channel, uid).ConfigureAwait(false); bannedSet = new HashSet<string>(bannedRecipients, StringComparer.Ordinal);
if (deliveryTargets.TryGetValue(connectionId, out var existing)) foreach (var bannedUid in bannedSet)
{ {
deliveryTargets[connectionId] = (existing.Uid, existing.IncludeSensitive || includeSensitive); _chatChannelService.RemovePresence(bannedUid);
await NotifyChatBanAsync(bannedUid).ConfigureAwait(false);
}
}
var deliveryTargets = new Dictionary<string, (string Uid, bool IncludeSensitive)>(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 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<Task>(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<Task>(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<ChatParticipantResolveResultDto?> 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")] [Authorize(Policy = "Identified")]
@@ -330,12 +331,6 @@ public partial class LightlessHub
{ {
var channel = request.Channel.WithNormalizedCustomKey(); 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)) 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); await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Unable to locate the reported message. It may have already expired.").ConfigureAwait(false);
@@ -442,7 +437,6 @@ public partial class LightlessHub
MessageId = messageEntry.MessageId, MessageId = messageEntry.MessageId,
MessageSentAtUtc = messageEntry.SentAtUtc, MessageSentAtUtc = messageEntry.SentAtUtc,
MessageContent = messageEntry.Message, MessageContent = messageEntry.Message,
SenderToken = messageEntry.SenderToken,
SenderHashedCid = messageEntry.SenderHashedCid, SenderHashedCid = messageEntry.SenderHashedCid,
SenderDisplayName = messageEntry.SenderUser?.AliasOrUID, SenderDisplayName = messageEntry.SenderUser?.AliasOrUID,
SenderWasLightfinder = messageEntry.SenderIsLightfinder, SenderWasLightfinder = messageEntry.SenderIsLightfinder,
@@ -457,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); 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) private ChatSenderDescriptor BuildSenderDescriptor(ChatChannelDescriptor descriptor, ChatParticipantInfo participant, bool includeSensitiveInfo = false)
{ {
var kind = descriptor.Type == ChatChannelType.Group var kind = descriptor.Type == ChatChannelType.Group
@@ -483,7 +562,7 @@ public partial class LightlessHub
? participant.HashedCid ? participant.HashedCid
: null; : null;
var canResolveProfile = kind == ChatSenderKind.IdentifiedUser || participant.IsLightfinder; var canResolveProfile = includeSensitiveInfo && (kind == ChatSenderKind.IdentifiedUser || participant.IsLightfinder);
return new ChatSenderDescriptor( return new ChatSenderDescriptor(
kind, kind,
@@ -494,44 +573,6 @@ public partial class LightlessHub
canResolveProfile); canResolveProfile);
} }
private async Task<UserProfileDto?> 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<int>());
}
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<int>());
}
if (profileData.ProfileDisabled)
{
return new UserProfileDto(userData, Disabled: true, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, Description: "This profile was permanently disabled", Tags: Array.Empty<int>());
}
return profileData.ToDTO();
}
private async Task<bool> ViewerAllowsLightfinderDetailsAsync(ChatChannelDescriptor descriptor) private async Task<bool> ViewerAllowsLightfinderDetailsAsync(ChatChannelDescriptor descriptor)
{ {
if (descriptor.Type == ChatChannelType.Group) if (descriptor.Type == ChatChannelType.Group)
@@ -566,7 +607,11 @@ public partial class LightlessHub
var (entry, expiry) = await TryGetBroadcastEntryAsync(presence.Participant.HashedCid!).ConfigureAwait(false); var (entry, expiry) = await TryGetBroadcastEntryAsync(presence.Participant.HashedCid!).ConfigureAwait(false);
if (!IsActiveBroadcastForUser(entry, expiry, userUid)) 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; return false;
} }
@@ -586,7 +631,7 @@ public partial class LightlessHub
if (!isBanned) if (!isBanned)
return false; return false;
_chatChannelService.RemovePresence(userUid); TryInvokeChatService(() => _chatChannelService.RemovePresence(userUid), "clearing presence for chat-banned user", targetUserUid: userUid);
await NotifyChatBanAsync(userUid).ConfigureAwait(false); await NotifyChatBanAsync(userUid).ConfigureAwait(false);
return true; return true;
} }

View File

@@ -464,7 +464,9 @@ public partial class LightlessHub
await db.StringSetAsync(broadcastKey, json, _broadcastConfiguration.BroadcastEntryTtl).ConfigureAwait(false); await db.StringSetAsync(broadcastKey, json, _broadcastConfiguration.BroadcastEntryTtl).ConfigureAwait(false);
await db.StringSetAsync(ownershipKey, hashedCid, _broadcastConfiguration.BroadcastEntryTtl).ConfigureAwait(false); await db.StringSetAsync(ownershipKey, hashedCid, _broadcastConfiguration.BroadcastEntryTtl).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args("broadcast enabled", hashedCid, "GID", gid)); _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 else
{ {
@@ -505,7 +507,9 @@ public partial class LightlessHub
} }
_logger.LogCallInfo(LightlessHubLogger.Args("broadcast disabled", hashedCid, "GID", entry.GID)); _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)");
} }
} }

View File

@@ -227,7 +227,7 @@ public partial class LightlessHub : Hub<ILightlessHub>, ILightlessHub
catch { } catch { }
finally finally
{ {
_chatChannelService.RemovePresence(UserUID); TryInvokeChatService(() => _chatChannelService.RemovePresence(UserUID), "removing chat presence on disconnect");
_userConnections.Remove(UserUID, out _); _userConnections.Remove(UserUID, out _);
} }
} }

View File

@@ -1,6 +1,4 @@
using System; using LightlessSync.API.Data;
using System.Collections.Generic;
using LightlessSync.API.Data;
using LightlessSync.API.Dto.Chat; using LightlessSync.API.Dto.Chat;
namespace LightlessSyncServer.Models; namespace LightlessSyncServer.Models;
@@ -32,7 +30,6 @@ public readonly record struct ChatMessageLogEntry(
string MessageId, string MessageId,
ChatChannelDescriptor Channel, ChatChannelDescriptor Channel,
DateTime SentAtUtc, DateTime SentAtUtc,
string SenderToken,
string SenderUserUid, string SenderUserUid,
UserData? SenderUser, UserData? SenderUser,
bool SenderIsLightfinder, bool SenderIsLightfinder,

View File

@@ -1,5 +1,4 @@
using System.Collections.Generic; using LightlessSync.API.Dto.Chat;
using LightlessSync.API.Dto.Chat;
namespace LightlessSyncServer.Models; namespace LightlessSyncServer.Models;
@@ -61,7 +60,6 @@ internal static class ChatZoneDefinitions
}, },
TerritoryIds: TerritoryRegistry.GetIds( TerritoryIds: TerritoryRegistry.GetIds(
"Ul'dah - Steps of Nald", "Ul'dah - Steps of Nald",
"Ul'dah - Steps of Thal")) "Ul'dah - Steps of Thal")),
}; };
} }

View File

@@ -1,15 +1,13 @@
using System; using System.Security.Cryptography;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using LightlessSync.API.Data; using LightlessSync.API.Data;
using LightlessSync.API.Dto.Chat; using LightlessSync.API.Dto.Chat;
using LightlessSyncServer.Configuration;
using LightlessSyncServer.Models; using LightlessSyncServer.Models;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options;
namespace LightlessSyncServer.Services; namespace LightlessSyncServer.Services;
public sealed class ChatChannelService public sealed class ChatChannelService : IDisposable
{ {
private readonly ILogger<ChatChannelService> _logger; private readonly ILogger<ChatChannelService> _logger;
private readonly Dictionary<string, ZoneChannelDefinition> _zoneDefinitions; private readonly Dictionary<string, ZoneChannelDefinition> _zoneDefinitions;
@@ -18,16 +16,137 @@ public sealed class ChatChannelService
private readonly Dictionary<ChannelKey, Dictionary<string, ChatParticipantInfo>> _participantsByChannel = new(); private readonly Dictionary<ChannelKey, Dictionary<string, ChatParticipantInfo>> _participantsByChannel = new();
private readonly Dictionary<ChannelKey, LinkedList<ChatMessageLogEntry>> _messagesByChannel = new(); private readonly Dictionary<ChannelKey, LinkedList<ChatMessageLogEntry>> _messagesByChannel = new();
private readonly Dictionary<string, (ChannelKey Channel, LinkedListNode<ChatMessageLogEntry> Node)> _messageIndex = new(StringComparer.Ordinal); private readonly Dictionary<string, (ChannelKey Channel, LinkedListNode<ChatMessageLogEntry> Node)> _messageIndex = new(StringComparer.Ordinal);
private readonly Dictionary<string, Dictionary<ChannelKey, HashSet<string>>> _mutedTokensByUser = new(StringComparer.Ordinal);
private readonly Dictionary<string, Dictionary<ChannelKey, HashSet<string>>> _mutedUidsByUser = new(StringComparer.Ordinal);
private readonly object _syncRoot = new(); private readonly object _syncRoot = new();
private const int MaxMessagesPerChannel = 200; private const int MaxMessagesPerChannel = 200;
internal const int MaxMutedParticipantsPerChannel = 8;
public ChatChannelService(ILogger<ChatChannelService> logger) public ChatChannelService(ILogger<ChatChannelService> logger, IOptions<ChatZoneOverridesOptions>? zoneOverrides = null)
{ {
_logger = logger; _logger = logger;
_zoneDefinitions = ChatZoneDefinitions.Defaults _zoneDefinitions = BuildZoneDefinitions(zoneOverrides?.Value);
.ToDictionary(definition => definition.Key, StringComparer.OrdinalIgnoreCase);
} }
private Dictionary<string, ZoneChannelDefinition> 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<ushort>();
if (entry.TerritoryIds is not null)
{
foreach (var candidate in entry.TerritoryIds)
{
if (candidate > 0)
{
territoryIds.Add(candidate);
}
}
}
var territoryNames = new HashSet<string>(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<ZoneChatChannelInfoDto> GetZoneChannelInfos() => public IReadOnlyList<ZoneChatChannelInfoDto> GetZoneChannelInfos() =>
_zoneDefinitions.Values _zoneDefinitions.Values
.Select(definition => new ZoneChatChannelInfoDto( .Select(definition => new ZoneChatChannelInfoDto(
@@ -154,7 +273,6 @@ public sealed class ChatChannelService
messageId, messageId,
channel, channel,
sentAtUtc, sentAtUtc,
participant.Token,
participant.UserUid, participant.UserUid,
participant.User, participant.User,
participant.IsLightfinder, participant.IsLightfinder,
@@ -260,23 +378,6 @@ public sealed class ChatChannelService
} }
} }
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;
}
public void RefreshLightfinderState(string userUid, string? hashedCid, bool isLightfinder) public void RefreshLightfinderState(string userUid, string? hashedCid, bool isLightfinder)
{ {
ArgumentException.ThrowIfNullOrEmpty(userUid); ArgumentException.ThrowIfNullOrEmpty(userUid);
@@ -413,6 +514,8 @@ public sealed class ChatChannelService
participantsByToken[token] = finalParticipant; participantsByToken[token] = finalParticipant;
ApplyUIDMuteIfPresent(normalizedDescriptor, finalParticipant);
_logger.LogDebug("Chat presence updated for {User} in {Channel}", userUid, Describe(key)); _logger.LogDebug("Chat presence updated for {User} in {Channel}", userUid, Describe(key));
return entryToStore; return entryToStore;
} }
@@ -446,9 +549,113 @@ public sealed class ChatChannelService
} }
} }
ClearMutesForChannel(userUid, key);
return true; return true;
} }
internal bool TryGetActiveParticipant(ChatChannelDescriptor channel, string token, out ChatParticipantInfo participant)
{
var key = ChannelKey.FromDescriptor(channel.WithNormalizedCustomKey());
lock (_syncRoot)
{
if (_participantsByChannel.TryGetValue(key, out var participants) &&
participants.TryGetValue(token, out participant))
{
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;
}
if (tokens.Contains(token))
{
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)
{
return ChatMuteUpdateResult.NoChange;
}
channels = new Dictionary<ChannelKey, HashSet<string>>();
_mutedTokensByUser[userUid] = channels;
}
if (!channels.TryGetValue(key, out var tokens))
{
if (!mute)
{
return ChatMuteUpdateResult.NoChange;
}
tokens = new HashSet<string>(StringComparer.Ordinal);
channels[key] = tokens;
}
if (mute)
{
if (!tokens.Contains(participant.Token) && tokens.Count >= MaxMutedParticipantsPerChannel)
{
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);
}
}
RemoveUIDMuteLocked(userUid, key, participant.UserUid);
return removed ? ChatMuteUpdateResult.Changed : ChatMuteUpdateResult.NoChange;
}
}
private static string GenerateToken() private static string GenerateToken()
{ {
Span<byte> buffer = stackalloc byte[8]; Span<byte> buffer = stackalloc byte[8];
@@ -458,4 +665,103 @@ public sealed class ChatChannelService
private static string Describe(ChannelKey key) private static string Describe(ChannelKey key)
=> $"{key.Type}:{key.WorldId}:{key.CustomKey}"; => $"{key.Type}:{key.WorldId}:{key.CustomKey}";
private void ClearMutesForChannel(string userUid, ChannelKey key)
{
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 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<ChannelKey, HashSet<string>>();
_mutedTokensByUser[muter] = tokenChannels;
}
if (!tokenChannels.TryGetValue(key, out var tokens))
{
tokens = new HashSet<string>(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<ChannelKey, HashSet<string>>();
_mutedUidsByUser[userUid] = channels;
}
if (!channels.TryGetValue(key, out var set))
{
set = new HashSet<string>(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
} }

View File

@@ -93,6 +93,7 @@ public class Startup
services.Configure<ServerConfiguration>(Configuration.GetRequiredSection("LightlessSync")); services.Configure<ServerConfiguration>(Configuration.GetRequiredSection("LightlessSync"));
services.Configure<LightlessConfigurationBase>(Configuration.GetRequiredSection("LightlessSync")); services.Configure<LightlessConfigurationBase>(Configuration.GetRequiredSection("LightlessSync"));
services.Configure<BroadcastOptions>(Configuration.GetSection("Broadcast")); services.Configure<BroadcastOptions>(Configuration.GetSection("Broadcast"));
services.Configure<ChatZoneOverridesOptions>(Configuration.GetSection("ChatZoneOverrides"));
services.AddSingleton<IBroadcastConfiguration, BroadcastConfiguration>(); services.AddSingleton<IBroadcastConfiguration, BroadcastConfiguration>();
services.AddSingleton<ServerTokenGenerator>(); services.AddSingleton<ServerTokenGenerator>();

View File

@@ -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;
}
}

View File

@@ -30,4 +30,14 @@ public class LightlessHubLogger
string formattedArgs = args != null && args.Length != 0 ? "|" + string.Join(":", args) : string.Empty; string formattedArgs = args != null && args.Length != 0 ? "|" + string.Join(":", args) : string.Empty;
_logger.LogWarning("{uid}:{method}{args}", _hub.UserUID, methodName, formattedArgs); _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);
}
} }

View File

@@ -41,6 +41,9 @@
"PairRequestRateLimit": 5, "PairRequestRateLimit": 5,
"PairRequestRateWindow": 60 "PairRequestRateWindow": 60
}, },
"ChatZoneOverrides": {
"Zones": []
},
"AllowedHosts": "*", "AllowedHosts": "*",
"Kestrel": { "Kestrel": {
"Endpoints": { "Endpoints": {

View File

@@ -204,7 +204,7 @@ internal class DiscordBot : IHostedService
var embed = await BuildChatReportEmbedAsync(dbContext, report, token).ConfigureAwait(false); var embed = await BuildChatReportEmbedAsync(dbContext, report, token).ConfigureAwait(false);
var components = new ComponentBuilder() 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("Dismiss", $"{ChatReportButtonPrefix}-dismiss-{report.ReportId}", ButtonStyle.Secondary)
.WithButton("Ban From Chat", $"{ChatReportButtonPrefix}-banchat-{report.ReportId}", ButtonStyle.Danger); .WithButton("Ban From Chat", $"{ChatReportButtonPrefix}-banchat-{report.ReportId}", ButtonStyle.Danger);
@@ -395,7 +395,7 @@ internal class DiscordBot : IHostedService
switch (action) switch (action)
{ {
case "resolve": case "resolve":
resolutionLabel = "Actioned"; resolutionLabel = "Resolved";
break; break;
case "dismiss": case "dismiss":
resolutionLabel = "Dismissed"; resolutionLabel = "Dismissed";
@@ -431,7 +431,7 @@ internal class DiscordBot : IHostedService
string responseText = action switch string responseText = action switch
{ {
"resolve" => "actioned", "resolve" => "resolved",
"dismiss" => "dismissed", "dismiss" => "dismissed",
"banchat" => "chat access revoked", "banchat" => "chat access revoked",
_ => "processed" _ => "processed"
@@ -473,7 +473,7 @@ internal class DiscordBot : IHostedService
embedBuilder.Fields.RemoveAll(f => string.Equals(f.Name, "Resolution", StringComparison.OrdinalIgnoreCase)); embedBuilder.Fields.RemoveAll(f => string.Equals(f.Name, "Resolution", StringComparison.OrdinalIgnoreCase));
var resolutionText = action switch var resolutionText = action switch
{ {
"resolve" => "Actioned", "resolve" => "Resolved",
"dismiss" => "Dismissed", "dismiss" => "Dismissed",
"banchat" => "Chat access revoked", "banchat" => "Chat access revoked",
_ => "Processed" _ => "Processed"

View File

@@ -40,9 +40,6 @@ public class ReportedChatMessage
[Required] [Required]
public string MessageContent { get; set; } = string.Empty; public string MessageContent { get; set; } = string.Empty;
[Required]
public string SenderToken { get; set; } = string.Empty;
public string? SenderHashedCid { get; set; } public string? SenderHashedCid { get; set; }
public string? SenderDisplayName { get; set; } public string? SenderDisplayName { get; set; }