just chat improvements :sludge:

This commit is contained in:
azyges
2025-12-17 03:46:23 +09:00
parent 1734f0e32f
commit 67fe2a1f0f
10 changed files with 434 additions and 411 deletions

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")]
@@ -436,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,
@@ -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); 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
@@ -477,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,
@@ -488,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)
@@ -560,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;
} }
@@ -580,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;
@@ -62,159 +61,5 @@ 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")),
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"))
}; };
} }

View File

@@ -14,20 +14,18 @@ public sealed class ChatChannelService : IDisposable
private readonly Dictionary<ChannelKey, HashSet<string>> _membersByChannel = new(); private readonly Dictionary<ChannelKey, HashSet<string>> _membersByChannel = new();
private readonly Dictionary<string, Dictionary<ChannelKey, ChatPresenceEntry>> _presenceByUser = new(StringComparer.Ordinal); private readonly Dictionary<string, Dictionary<ChannelKey, ChatPresenceEntry>> _presenceByUser = new(StringComparer.Ordinal);
private readonly Dictionary<ChannelKey, Dictionary<string, ChatParticipantInfo>> _participantsByChannel = new(); private readonly Dictionary<ChannelKey, Dictionary<string, ChatParticipantInfo>> _participantsByChannel = new();
private readonly Dictionary<ChannelKey, Dictionary<string, InactiveParticipantEntry>> _inactiveParticipantsByChannel = 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;
private static readonly TimeSpan InactiveParticipantRetention = TimeSpan.FromMinutes(15); internal const int MaxMutedParticipantsPerChannel = 8;
private static readonly TimeSpan InactiveParticipantCleanupInterval = TimeSpan.FromMinutes(1);
private readonly Timer _inactiveParticipantCleanupTimer;
public ChatChannelService(ILogger<ChatChannelService> logger, IOptions<ChatZoneOverridesOptions>? zoneOverrides = null) public ChatChannelService(ILogger<ChatChannelService> logger, IOptions<ChatZoneOverridesOptions>? zoneOverrides = null)
{ {
_logger = logger; _logger = logger;
_zoneDefinitions = BuildZoneDefinitions(zoneOverrides?.Value); _zoneDefinitions = BuildZoneDefinitions(zoneOverrides?.Value);
_inactiveParticipantCleanupTimer = new Timer(_ => CleanupExpiredInactiveParticipants(), null, InactiveParticipantCleanupInterval, InactiveParticipantCleanupInterval);
} }
private Dictionary<string, ZoneChannelDefinition> BuildZoneDefinitions(ChatZoneOverridesOptions? overrides) private Dictionary<string, ZoneChannelDefinition> BuildZoneDefinitions(ChatZoneOverridesOptions? overrides)
@@ -275,7 +273,6 @@ public sealed class ChatChannelService : IDisposable
messageId, messageId,
channel, channel,
sentAtUtc, sentAtUtc,
participant.Token,
participant.UserUid, participant.UserUid,
participant.User, participant.User,
participant.IsLightfinder, 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) public void RefreshLightfinderState(string userUid, string? hashedCid, bool isLightfinder)
{ {
ArgumentException.ThrowIfNullOrEmpty(userUid); ArgumentException.ThrowIfNullOrEmpty(userUid);
@@ -549,7 +513,8 @@ public sealed class ChatChannelService : IDisposable
} }
participantsByToken[token] = finalParticipant; participantsByToken[token] = finalParticipant;
RemoveInactiveParticipantLocked(key, token);
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;
@@ -584,74 +549,110 @@ public sealed class ChatChannelService : IDisposable
} }
} }
StoreInactiveParticipantLocked(key, existing.Participant); ClearMutesForChannel(userUid, key);
return true; return true;
} }
private void StoreInactiveParticipantLocked(ChannelKey key, ChatParticipantInfo participant) internal bool TryGetActiveParticipant(ChatChannelDescriptor channel, string token, out ChatParticipantInfo participant)
{ {
if (string.IsNullOrEmpty(participant.Token)) var key = ChannelKey.FromDescriptor(channel.WithNormalizedCustomKey());
{
return;
}
if (!_inactiveParticipantsByChannel.TryGetValue(key, out var inactive))
{
inactive = new Dictionary<string, InactiveParticipantEntry>(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) 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; if (tokens.Contains(token))
var channelsToRemove = new List<ChannelKey>();
foreach (var (key, inactive) in _inactiveParticipantsByChannel)
{ {
var tokensToRemove = new List<string>(); return true;
foreach (var (token, entry) in inactive) }
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) return ChatMuteUpdateResult.NoChange;
{
tokensToRemove.Add(token);
}
} }
foreach (var token in tokensToRemove) channels = new Dictionary<ChannelKey, HashSet<string>>();
_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<string>(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) RemoveUIDMuteLocked(userUid, key, participant.UserUid);
{ return removed ? ChatMuteUpdateResult.Changed : ChatMuteUpdateResult.NoChange;
_inactiveParticipantsByChannel.Remove(channel);
}
} }
} }
@@ -665,10 +666,102 @@ public sealed class ChatChannelService : IDisposable
private static string Describe(ChannelKey key) private static string Describe(ChannelKey key)
=> $"{key.Type}:{key.WorldId}:{key.CustomKey}"; => $"{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<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

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

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