462 lines
14 KiB
C#
462 lines
14 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Security.Cryptography;
|
|
using LightlessSync.API.Data;
|
|
using LightlessSync.API.Dto.Chat;
|
|
using LightlessSyncServer.Models;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace LightlessSyncServer.Services;
|
|
|
|
public sealed class ChatChannelService
|
|
{
|
|
private readonly ILogger<ChatChannelService> _logger;
|
|
private readonly Dictionary<string, ZoneChannelDefinition> _zoneDefinitions;
|
|
private readonly Dictionary<ChannelKey, HashSet<string>> _membersByChannel = new();
|
|
private readonly Dictionary<string, Dictionary<ChannelKey, ChatPresenceEntry>> _presenceByUser = new(StringComparer.Ordinal);
|
|
private readonly Dictionary<ChannelKey, Dictionary<string, ChatParticipantInfo>> _participantsByChannel = new();
|
|
private readonly Dictionary<ChannelKey, LinkedList<ChatMessageLogEntry>> _messagesByChannel = new();
|
|
private readonly Dictionary<string, (ChannelKey Channel, LinkedListNode<ChatMessageLogEntry> Node)> _messageIndex = new(StringComparer.Ordinal);
|
|
private readonly object _syncRoot = new();
|
|
private const int MaxMessagesPerChannel = 200;
|
|
|
|
public ChatChannelService(ILogger<ChatChannelService> logger)
|
|
{
|
|
_logger = logger;
|
|
_zoneDefinitions = ChatZoneDefinitions.Defaults
|
|
.ToDictionary(definition => definition.Key, StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
|
|
public IReadOnlyList<ZoneChatChannelInfoDto> GetZoneChannelInfos() =>
|
|
_zoneDefinitions.Values
|
|
.Select(definition => new ZoneChatChannelInfoDto(
|
|
definition.Descriptor,
|
|
definition.DisplayName,
|
|
definition.TerritoryNames))
|
|
.ToArray();
|
|
|
|
public bool TryResolveZone(string? key, out ZoneChannelDefinition definition)
|
|
{
|
|
definition = default;
|
|
if (string.IsNullOrWhiteSpace(key))
|
|
return false;
|
|
|
|
return _zoneDefinitions.TryGetValue(key, out definition);
|
|
}
|
|
|
|
public ChatPresenceEntry? UpdateZonePresence(
|
|
string userUid,
|
|
ZoneChannelDefinition definition,
|
|
ushort worldId,
|
|
ushort territoryId,
|
|
string? hashedCid,
|
|
bool isLightfinder,
|
|
bool isActive)
|
|
{
|
|
if (worldId == 0 || !WorldRegistry.IsKnownWorld(worldId))
|
|
{
|
|
_logger.LogWarning("Rejected zone chat presence for {User} in {Zone}: unknown world {WorldId}", userUid, definition.Key, worldId);
|
|
return null;
|
|
}
|
|
|
|
if (!definition.TerritoryIds.Contains(territoryId))
|
|
{
|
|
_logger.LogWarning("Rejected zone chat presence for {User} in {Zone}: invalid territory {TerritoryId}", userUid, definition.Key, territoryId);
|
|
return null;
|
|
}
|
|
|
|
var descriptor = definition.Descriptor with { WorldId = worldId, ZoneId = territoryId };
|
|
var participant = new ChatParticipantInfo(
|
|
Token: string.Empty,
|
|
UserUid: userUid,
|
|
User: null,
|
|
HashedCid: isLightfinder ? hashedCid : null,
|
|
IsLightfinder: isLightfinder);
|
|
|
|
return UpdatePresence(
|
|
userUid,
|
|
descriptor,
|
|
definition.DisplayName,
|
|
participant,
|
|
isActive,
|
|
replaceExistingOfSameType: true);
|
|
}
|
|
|
|
public ChatPresenceEntry? UpdateGroupPresence(
|
|
string userUid,
|
|
string groupId,
|
|
string displayName,
|
|
UserData user,
|
|
string? hashedCid,
|
|
bool isActive)
|
|
{
|
|
var descriptor = new ChatChannelDescriptor
|
|
{
|
|
Type = ChatChannelType.Group,
|
|
WorldId = 0,
|
|
ZoneId = 0,
|
|
CustomKey = groupId
|
|
};
|
|
|
|
var participant = new ChatParticipantInfo(
|
|
Token: string.Empty,
|
|
UserUid: userUid,
|
|
User: user,
|
|
HashedCid: hashedCid,
|
|
IsLightfinder: !string.IsNullOrEmpty(hashedCid));
|
|
|
|
return UpdatePresence(
|
|
userUid,
|
|
descriptor,
|
|
displayName,
|
|
participant,
|
|
isActive,
|
|
replaceExistingOfSameType: false);
|
|
}
|
|
|
|
public bool TryGetPresence(string userUid, ChatChannelDescriptor channel, out ChatPresenceEntry presence)
|
|
{
|
|
var key = ChannelKey.FromDescriptor(channel);
|
|
|
|
lock (_syncRoot)
|
|
{
|
|
if (_presenceByUser.TryGetValue(userUid, out var entries) && entries.TryGetValue(key, out presence))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
presence = default;
|
|
return false;
|
|
}
|
|
|
|
public IReadOnlyCollection<string> GetMembers(ChatChannelDescriptor channel)
|
|
{
|
|
var key = ChannelKey.FromDescriptor(channel);
|
|
|
|
lock (_syncRoot)
|
|
{
|
|
if (_membersByChannel.TryGetValue(key, out var members))
|
|
{
|
|
return members.ToArray();
|
|
}
|
|
}
|
|
|
|
return Array.Empty<string>();
|
|
}
|
|
|
|
public string RecordMessage(ChatChannelDescriptor channel, ChatParticipantInfo participant, string message, DateTime sentAtUtc)
|
|
{
|
|
var key = ChannelKey.FromDescriptor(channel);
|
|
var messageId = Guid.NewGuid().ToString("N");
|
|
var entry = new ChatMessageLogEntry(
|
|
messageId,
|
|
channel,
|
|
sentAtUtc,
|
|
participant.Token,
|
|
participant.UserUid,
|
|
participant.User,
|
|
participant.IsLightfinder,
|
|
participant.HashedCid,
|
|
message);
|
|
|
|
lock (_syncRoot)
|
|
{
|
|
if (!_messagesByChannel.TryGetValue(key, out var list))
|
|
{
|
|
list = new LinkedList<ChatMessageLogEntry>();
|
|
_messagesByChannel[key] = list;
|
|
}
|
|
|
|
var node = list.AddLast(entry);
|
|
_messageIndex[messageId] = (key, node);
|
|
|
|
while (list.Count > MaxMessagesPerChannel)
|
|
{
|
|
var removedNode = list.First;
|
|
if (removedNode is null)
|
|
{
|
|
break;
|
|
}
|
|
|
|
list.RemoveFirst();
|
|
_messageIndex.Remove(removedNode.Value.MessageId);
|
|
}
|
|
}
|
|
|
|
return messageId;
|
|
}
|
|
|
|
public bool TryGetMessage(string messageId, out ChatMessageLogEntry entry)
|
|
{
|
|
lock (_syncRoot)
|
|
{
|
|
if (_messageIndex.TryGetValue(messageId, out var located))
|
|
{
|
|
entry = located.Node.Value;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
entry = default;
|
|
return false;
|
|
}
|
|
|
|
public IReadOnlyList<ChatMessageLogEntry> GetRecentMessages(ChatChannelDescriptor descriptor, int maxCount)
|
|
{
|
|
lock (_syncRoot)
|
|
{
|
|
var key = ChannelKey.FromDescriptor(descriptor);
|
|
if (!_messagesByChannel.TryGetValue(key, out var list) || list.Count == 0)
|
|
{
|
|
return Array.Empty<ChatMessageLogEntry>();
|
|
}
|
|
|
|
var take = Math.Min(maxCount, list.Count);
|
|
var result = new ChatMessageLogEntry[take];
|
|
var node = list.Last;
|
|
for (var i = take - 1; i >= 0 && node is not null; i--)
|
|
{
|
|
result[i] = node.Value;
|
|
node = node.Previous;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
}
|
|
|
|
public bool RemovePresence(string userUid, ChatChannelDescriptor? channel = null)
|
|
{
|
|
ArgumentException.ThrowIfNullOrEmpty(userUid);
|
|
|
|
lock (_syncRoot)
|
|
{
|
|
if (!_presenceByUser.TryGetValue(userUid, out var entries))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (channel is null)
|
|
{
|
|
foreach (var existing in entries.Keys.ToList())
|
|
{
|
|
RemovePresenceInternal(userUid, entries, existing);
|
|
}
|
|
|
|
_presenceByUser.Remove(userUid);
|
|
return true;
|
|
}
|
|
|
|
var key = ChannelKey.FromDescriptor(channel.Value);
|
|
var removed = RemovePresenceInternal(userUid, entries, key);
|
|
|
|
if (entries.Count == 0)
|
|
{
|
|
_presenceByUser.Remove(userUid);
|
|
}
|
|
|
|
return removed;
|
|
}
|
|
}
|
|
|
|
public bool TryResolveParticipant(ChatChannelDescriptor channel, string token, out ChatParticipantInfo participant)
|
|
{
|
|
var key = ChannelKey.FromDescriptor(channel);
|
|
|
|
lock (_syncRoot)
|
|
{
|
|
if (_participantsByChannel.TryGetValue(key, out var participants) &&
|
|
participants.TryGetValue(token, out participant))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
participant = default;
|
|
return false;
|
|
}
|
|
|
|
public void RefreshLightfinderState(string userUid, string? hashedCid, bool isLightfinder)
|
|
{
|
|
ArgumentException.ThrowIfNullOrEmpty(userUid);
|
|
|
|
lock (_syncRoot)
|
|
{
|
|
if (!_presenceByUser.TryGetValue(userUid, out var entries) || entries.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var (key, existing) in entries.ToArray())
|
|
{
|
|
var updatedParticipant = existing.Participant with
|
|
{
|
|
HashedCid = isLightfinder ? hashedCid : null,
|
|
IsLightfinder = isLightfinder
|
|
};
|
|
|
|
var updatedEntry = existing with
|
|
{
|
|
Participant = updatedParticipant,
|
|
UpdatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
entries[key] = updatedEntry;
|
|
|
|
if (_participantsByChannel.TryGetValue(key, out var participants))
|
|
{
|
|
participants[updatedParticipant.Token] = updatedParticipant;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private ChatPresenceEntry? UpdatePresence(
|
|
string userUid,
|
|
ChatChannelDescriptor descriptor,
|
|
string displayName,
|
|
ChatParticipantInfo participant,
|
|
bool isActive,
|
|
bool replaceExistingOfSameType)
|
|
{
|
|
ArgumentException.ThrowIfNullOrEmpty(userUid);
|
|
|
|
var normalizedDescriptor = descriptor.WithNormalizedCustomKey();
|
|
var key = ChannelKey.FromDescriptor(normalizedDescriptor);
|
|
|
|
lock (_syncRoot)
|
|
{
|
|
if (!_presenceByUser.TryGetValue(userUid, out var entries))
|
|
{
|
|
if (!isActive)
|
|
return null;
|
|
|
|
entries = new Dictionary<ChannelKey, ChatPresenceEntry>();
|
|
_presenceByUser[userUid] = entries;
|
|
}
|
|
|
|
string? reusableToken = null;
|
|
|
|
if (entries.TryGetValue(key, out var existing))
|
|
{
|
|
reusableToken = existing.Participant.Token;
|
|
RemovePresenceInternal(userUid, entries, key);
|
|
}
|
|
|
|
if (replaceExistingOfSameType)
|
|
{
|
|
foreach (var candidate in entries.Keys.Where(k => k.Type == key.Type).ToList())
|
|
{
|
|
if (entries.TryGetValue(candidate, out var entry))
|
|
{
|
|
reusableToken ??= entry.Participant.Token;
|
|
}
|
|
|
|
RemovePresenceInternal(userUid, entries, candidate);
|
|
}
|
|
|
|
if (!isActive)
|
|
{
|
|
if (entries.Count == 0)
|
|
{
|
|
_presenceByUser.Remove(userUid);
|
|
}
|
|
|
|
_logger.LogDebug("Chat presence cleared for {User} ({Type})", userUid, normalizedDescriptor.Type);
|
|
return null;
|
|
}
|
|
}
|
|
else if (!isActive)
|
|
{
|
|
var removed = RemovePresenceInternal(userUid, entries, key);
|
|
if (removed)
|
|
{
|
|
_logger.LogDebug("Chat presence removed for {User} from {Channel}", userUid, Describe(key));
|
|
}
|
|
|
|
if (entries.Count == 0)
|
|
{
|
|
_presenceByUser.Remove(userUid);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
var token = !string.IsNullOrEmpty(participant.Token)
|
|
? participant.Token
|
|
: reusableToken ?? GenerateToken();
|
|
|
|
var finalParticipant = participant with { Token = token };
|
|
var entryToStore = new ChatPresenceEntry(
|
|
normalizedDescriptor,
|
|
key,
|
|
displayName,
|
|
finalParticipant,
|
|
DateTime.UtcNow);
|
|
|
|
entries[key] = entryToStore;
|
|
|
|
if (!_membersByChannel.TryGetValue(key, out var members))
|
|
{
|
|
members = new HashSet<string>(StringComparer.Ordinal);
|
|
_membersByChannel[key] = members;
|
|
}
|
|
|
|
members.Add(userUid);
|
|
|
|
if (!_participantsByChannel.TryGetValue(key, out var participantsByToken))
|
|
{
|
|
participantsByToken = new Dictionary<string, ChatParticipantInfo>(StringComparer.Ordinal);
|
|
_participantsByChannel[key] = participantsByToken;
|
|
}
|
|
|
|
participantsByToken[token] = finalParticipant;
|
|
|
|
_logger.LogDebug("Chat presence updated for {User} in {Channel}", userUid, Describe(key));
|
|
return entryToStore;
|
|
}
|
|
}
|
|
|
|
private bool RemovePresenceInternal(string userUid, Dictionary<ChannelKey, ChatPresenceEntry> entries, ChannelKey key)
|
|
{
|
|
if (!entries.TryGetValue(key, out var existing))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
entries.Remove(key);
|
|
|
|
if (_membersByChannel.TryGetValue(key, out var members))
|
|
{
|
|
members.Remove(userUid);
|
|
if (members.Count == 0)
|
|
{
|
|
_membersByChannel.Remove(key);
|
|
// Preserve message history even when a channel becomes empty so moderation can still resolve reports.
|
|
}
|
|
}
|
|
|
|
if (_participantsByChannel.TryGetValue(key, out var participants))
|
|
{
|
|
participants.Remove(existing.Participant.Token);
|
|
if (participants.Count == 0)
|
|
{
|
|
_participantsByChannel.Remove(key);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static string GenerateToken()
|
|
{
|
|
Span<byte> buffer = stackalloc byte[8];
|
|
RandomNumberGenerator.Fill(buffer);
|
|
return Convert.ToHexString(buffer);
|
|
}
|
|
|
|
private static string Describe(ChannelKey key)
|
|
=> $"{key.Type}:{key.WorldId}:{key.CustomKey}";
|
|
}
|