Files
LightlessServer/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs
2025-12-17 03:46:23 +09:00

768 lines
24 KiB
C#

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;
public sealed class ChatChannelService : IDisposable
{
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 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 const int MaxMessagesPerChannel = 200;
internal const int MaxMutedParticipantsPerChannel = 8;
public ChatChannelService(ILogger<ChatChannelService> logger, IOptions<ChatZoneOverridesOptions>? zoneOverrides = null)
{
_logger = logger;
_zoneDefinitions = BuildZoneDefinitions(zoneOverrides?.Value);
}
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() =>
_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.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 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;
ApplyUIDMuteIfPresent(normalizedDescriptor, 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);
}
}
ClearMutesForChannel(userUid, key);
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()
{
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}";
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
}