chat
This commit is contained in:
@@ -0,0 +1,578 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using LightlessSync.API.Data.Enum;
|
||||
using LightlessSync.API.Dto.Chat;
|
||||
using LightlessSync.API.Dto.User;
|
||||
using LightlessSyncServer.Services;
|
||||
using LightlessSyncServer.Utils;
|
||||
using LightlessSyncShared.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace LightlessSyncServer.Hubs;
|
||||
|
||||
public partial class LightlessHub
|
||||
{
|
||||
private const int MaxChatMessageLength = 400;
|
||||
private static readonly JsonSerializerOptions ChatReportSnapshotSerializerOptions = new(JsonSerializerDefaults.General)
|
||||
{
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
[Authorize(Policy = "Identified")]
|
||||
public Task<IReadOnlyList<ZoneChatChannelInfoDto>> GetZoneChatChannels()
|
||||
{
|
||||
return Task.FromResult(_chatChannelService.GetZoneChannelInfos());
|
||||
}
|
||||
|
||||
[Authorize(Policy = "Identified")]
|
||||
public async Task<IReadOnlyList<GroupChatChannelInfoDto>> GetGroupChatChannels()
|
||||
{
|
||||
var userUid = UserUID;
|
||||
|
||||
var groupInfos = await DbContext.Groups
|
||||
.AsNoTracking()
|
||||
.Where(g => g.OwnerUID == userUid
|
||||
|| DbContext.GroupPairs.Any(p => p.GroupGID == g.GID && p.GroupUserUID == userUid))
|
||||
.ToListAsync()
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return groupInfos
|
||||
.Select(g =>
|
||||
{
|
||||
var displayName = string.IsNullOrWhiteSpace(g.Alias) ? g.GID : g.Alias!;
|
||||
var descriptor = new ChatChannelDescriptor
|
||||
{
|
||||
Type = ChatChannelType.Group,
|
||||
WorldId = 0,
|
||||
ZoneId = 0,
|
||||
CustomKey = g.GID
|
||||
};
|
||||
|
||||
return new GroupChatChannelInfoDto(
|
||||
descriptor,
|
||||
displayName,
|
||||
g.GID,
|
||||
g.OwnerUID == userUid);
|
||||
})
|
||||
.OrderBy(info => info.DisplayName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
[Authorize(Policy = "Identified")]
|
||||
public async Task UpdateChatPresence(ChatPresenceUpdateDto presence)
|
||||
{
|
||||
var channel = presence.Channel.WithNormalizedCustomKey();
|
||||
|
||||
var userRecord = await DbContext.Users
|
||||
.AsNoTracking()
|
||||
.SingleAsync(u => u.UID == UserUID, cancellationToken: RequestAbortedToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (userRecord.ChatBanned)
|
||||
{
|
||||
_chatChannelService.RemovePresence(UserUID);
|
||||
await NotifyChatBanAsync(UserUID).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!presence.IsActive)
|
||||
{
|
||||
_chatChannelService.RemovePresence(UserUID, channel);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (channel.Type)
|
||||
{
|
||||
case ChatChannelType.Zone:
|
||||
if (!_chatChannelService.TryResolveZone(channel.CustomKey, out var definition))
|
||||
{
|
||||
throw new HubException("Unsupported chat channel.");
|
||||
}
|
||||
|
||||
string? hashedCid = null;
|
||||
var isLightfinder = false;
|
||||
if (IsValidHashedCid(UserCharaIdent))
|
||||
{
|
||||
var (entry, expiry) = await TryGetBroadcastEntryAsync(UserCharaIdent).ConfigureAwait(false);
|
||||
isLightfinder = HasActiveBroadcast(entry, expiry);
|
||||
if (isLightfinder)
|
||||
{
|
||||
hashedCid = UserCharaIdent;
|
||||
}
|
||||
}
|
||||
|
||||
_chatChannelService.UpdateZonePresence(
|
||||
UserUID,
|
||||
definition,
|
||||
channel.WorldId,
|
||||
hashedCid,
|
||||
isLightfinder,
|
||||
isActive: true);
|
||||
break;
|
||||
|
||||
case ChatChannelType.Group:
|
||||
var groupKey = channel.CustomKey ?? string.Empty;
|
||||
if (string.IsNullOrEmpty(groupKey))
|
||||
{
|
||||
throw new HubException("Unsupported chat channel.");
|
||||
}
|
||||
|
||||
var userData = userRecord.ToUserData();
|
||||
|
||||
var group = await DbContext.Groups
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(g => g.GID == groupKey, cancellationToken: RequestAbortedToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (group is null)
|
||||
{
|
||||
throw new HubException("Unsupported chat channel.");
|
||||
}
|
||||
|
||||
var isMember = string.Equals(group.OwnerUID, UserUID, StringComparison.Ordinal)
|
||||
|| await DbContext.GroupPairs
|
||||
.AsNoTracking()
|
||||
.AnyAsync(gp => gp.GroupGID == groupKey && gp.GroupUserUID == UserUID, cancellationToken: RequestAbortedToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!isMember)
|
||||
{
|
||||
throw new HubException("Join the syncshell before using chat.");
|
||||
}
|
||||
|
||||
var displayName = string.IsNullOrWhiteSpace(group.Alias) ? group.GID : group.Alias;
|
||||
|
||||
_chatChannelService.UpdateGroupPresence(
|
||||
UserUID,
|
||||
group.GID,
|
||||
displayName,
|
||||
userData,
|
||||
IsValidHashedCid(UserCharaIdent) ? UserCharaIdent : null,
|
||||
isActive: true);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new HubException("Unsupported chat channel.");
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Policy = "Identified")]
|
||||
public async Task SendChatMessage(ChatSendRequestDto request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Message))
|
||||
{
|
||||
throw new HubException("Message cannot be empty.");
|
||||
}
|
||||
|
||||
var channel = request.Channel.WithNormalizedCustomKey();
|
||||
|
||||
if (await HandleIfChatBannedAsync(UserUID).ConfigureAwait(false))
|
||||
{
|
||||
throw new HubException("Chat access has been revoked.");
|
||||
}
|
||||
|
||||
if (!_chatChannelService.TryGetPresence(UserUID, channel, out var presence))
|
||||
{
|
||||
throw new HubException("Join a chat channel before sending messages.");
|
||||
}
|
||||
|
||||
var sanitizedMessage = request.Message.Trim().ReplaceLineEndings(" ");
|
||||
if (sanitizedMessage.Length > MaxChatMessageLength)
|
||||
{
|
||||
sanitizedMessage = sanitizedMessage[..MaxChatMessageLength];
|
||||
}
|
||||
|
||||
var recipients = _chatChannelService.GetMembers(presence.Channel);
|
||||
var recipientsList = recipients.ToList();
|
||||
if (recipientsList.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
bannedSet = new HashSet<string>(bannedRecipients, StringComparer.Ordinal);
|
||||
foreach (var bannedUid in bannedSet)
|
||||
{
|
||||
_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))
|
||||
{
|
||||
var includeSensitive = AllowsLightfinderDetails(presence.Channel, uid);
|
||||
if (deliveryTargets.TryGetValue(connectionId, out var existing))
|
||||
{
|
||||
deliveryTargets[connectionId] = (existing.Uid, existing.IncludeSensitive || includeSensitive);
|
||||
}
|
||||
else
|
||||
{
|
||||
deliveryTargets[connectionId] = (uid, includeSensitive);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_chatChannelService.RemovePresence(uid);
|
||||
}
|
||||
}
|
||||
|
||||
if (deliveryTargets.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
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")]
|
||||
public async Task ReportChatMessage(ChatReportSubmitDto request)
|
||||
{
|
||||
var channel = request.Channel.WithNormalizedCustomKey();
|
||||
|
||||
if (!_chatChannelService.TryGetPresence(UserUID, channel, out _))
|
||||
{
|
||||
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Join the chat channel before reporting messages.").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_chatChannelService.TryGetMessage(request.MessageId, out var messageEntry))
|
||||
{
|
||||
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Unable to locate the reported message. It may have already expired.").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var requestedChannelKey = ChatChannelService.ChannelKey.FromDescriptor(channel);
|
||||
var messageChannelKey = ChatChannelService.ChannelKey.FromDescriptor(messageEntry.Channel.WithNormalizedCustomKey());
|
||||
if (!requestedChannelKey.Equals(messageChannelKey))
|
||||
{
|
||||
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "The reported message no longer matches this channel.").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(messageEntry.SenderUserUid, UserUID, StringComparison.Ordinal))
|
||||
{
|
||||
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You cannot report your own message.").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var reason = request.Reason?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
{
|
||||
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "Please provide a short explanation for the report.").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const int MaxReasonLength = 500;
|
||||
if (reason.Length > MaxReasonLength)
|
||||
{
|
||||
reason = reason[..MaxReasonLength];
|
||||
}
|
||||
|
||||
var additionalContext = string.IsNullOrWhiteSpace(request.AdditionalContext)
|
||||
? null
|
||||
: request.AdditionalContext.Trim();
|
||||
|
||||
const int MaxContextLength = 1000;
|
||||
if (!string.IsNullOrEmpty(additionalContext) && additionalContext.Length > MaxContextLength)
|
||||
{
|
||||
additionalContext = additionalContext[..MaxContextLength];
|
||||
}
|
||||
|
||||
var alreadyReported = await DbContext.ReportedChatMessages
|
||||
.AsNoTracking()
|
||||
.AnyAsync(r => r.MessageId == request.MessageId && r.ReporterUserUid == UserUID && !r.Resolved, cancellationToken: RequestAbortedToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (alreadyReported)
|
||||
{
|
||||
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You already reported this message and it is pending review.").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var oneHourAgo = DateTime.UtcNow - TimeSpan.FromHours(1);
|
||||
var reportRateLimited = await DbContext.ReportedChatMessages
|
||||
.AsNoTracking()
|
||||
.AnyAsync(r => r.ReporterUserUid == UserUID && r.ReportTimeUtc >= oneHourAgo, cancellationToken: RequestAbortedToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (reportRateLimited)
|
||||
{
|
||||
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You can file at most one chat report per hour.").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(messageEntry.SenderUserUid))
|
||||
{
|
||||
var targetAlreadyPending = await DbContext.ReportedChatMessages
|
||||
.AsNoTracking()
|
||||
.AnyAsync(r => r.ReportedUserUid == messageEntry.SenderUserUid && !r.Resolved, cancellationToken: RequestAbortedToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (targetAlreadyPending)
|
||||
{
|
||||
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, "This user already has a report pending review.").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var snapshotEntries = _chatChannelService.GetRecentMessages(messageEntry.Channel, 25);
|
||||
var snapshotItems = snapshotEntries
|
||||
.Select(e => new ChatReportSnapshotItem(
|
||||
e.MessageId,
|
||||
e.SentAtUtc,
|
||||
e.SenderUserUid,
|
||||
e.SenderUser?.AliasOrUID,
|
||||
e.SenderIsLightfinder,
|
||||
e.SenderHashedCid,
|
||||
e.Message))
|
||||
.ToArray();
|
||||
|
||||
var snapshotJson = JsonSerializer.Serialize(snapshotItems, ChatReportSnapshotSerializerOptions);
|
||||
|
||||
var report = new ReportedChatMessage
|
||||
{
|
||||
ReportTimeUtc = DateTime.UtcNow,
|
||||
ReporterUserUid = UserUID,
|
||||
ReportedUserUid = messageEntry.SenderUserUid,
|
||||
ChannelType = messageEntry.Channel.Type,
|
||||
WorldId = messageEntry.Channel.WorldId,
|
||||
ZoneId = messageEntry.Channel.ZoneId,
|
||||
ChannelKey = messageEntry.Channel.CustomKey ?? string.Empty,
|
||||
MessageId = messageEntry.MessageId,
|
||||
MessageSentAtUtc = messageEntry.SentAtUtc,
|
||||
MessageContent = messageEntry.Message,
|
||||
SenderToken = messageEntry.SenderToken,
|
||||
SenderHashedCid = messageEntry.SenderHashedCid,
|
||||
SenderDisplayName = messageEntry.SenderUser?.AliasOrUID,
|
||||
SenderWasLightfinder = messageEntry.SenderIsLightfinder,
|
||||
SnapshotJson = snapshotJson,
|
||||
Reason = reason,
|
||||
AdditionalContext = additionalContext
|
||||
};
|
||||
|
||||
DbContext.ReportedChatMessages.Add(report);
|
||||
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
|
||||
|
||||
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, "Thank you. Your report has been queued for moderator review.").ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private ChatSenderDescriptor BuildSenderDescriptor(ChatChannelDescriptor descriptor, ChatChannelService.ChatParticipantInfo participant, bool includeSensitiveInfo = false)
|
||||
{
|
||||
var kind = descriptor.Type == ChatChannelType.Group
|
||||
? ChatSenderKind.IdentifiedUser
|
||||
: ChatSenderKind.Anonymous;
|
||||
|
||||
string? displayName;
|
||||
if (kind == ChatSenderKind.IdentifiedUser)
|
||||
{
|
||||
displayName = participant.User?.Alias ?? participant.User?.UID ?? participant.UserUid;
|
||||
}
|
||||
else if (includeSensitiveInfo && participant.IsLightfinder && !string.IsNullOrEmpty(participant.HashedCid))
|
||||
{
|
||||
displayName = participant.HashedCid;
|
||||
}
|
||||
else
|
||||
{
|
||||
var source = participant.UserUid ?? string.Empty;
|
||||
var suffix = source.Length >= 4 ? source[^4..] : source;
|
||||
displayName = string.IsNullOrEmpty(suffix) ? "Anonymous" : $"Anon-{suffix}";
|
||||
}
|
||||
|
||||
var hashedCid = includeSensitiveInfo && participant.IsLightfinder
|
||||
? participant.HashedCid
|
||||
: null;
|
||||
|
||||
var canResolveProfile = kind == ChatSenderKind.IdentifiedUser || participant.IsLightfinder;
|
||||
|
||||
return new ChatSenderDescriptor(
|
||||
kind,
|
||||
participant.Token,
|
||||
displayName,
|
||||
hashedCid,
|
||||
descriptor.Type == ChatChannelType.Group ? participant.User : null,
|
||||
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)
|
||||
{
|
||||
if (descriptor.Type == ChatChannelType.Group)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var viewerCid = UserCharaIdent;
|
||||
if (!IsValidHashedCid(viewerCid))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var (entry, expiry) = await TryGetBroadcastEntryAsync(viewerCid).ConfigureAwait(false);
|
||||
return HasActiveBroadcast(entry, expiry);
|
||||
}
|
||||
|
||||
private bool AllowsLightfinderDetails(ChatChannelDescriptor descriptor, string userUid)
|
||||
{
|
||||
if (descriptor.Type == ChatChannelType.Group)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_chatChannelService.TryGetPresence(userUid, descriptor, out var presence))
|
||||
{
|
||||
return presence.Participant.IsLightfinder;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<bool> HandleIfChatBannedAsync(string userUid)
|
||||
{
|
||||
var isBanned = await DbContext.Users
|
||||
.AsNoTracking()
|
||||
.AnyAsync(u => u.UID == userUid && u.ChatBanned, RequestAbortedToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!isBanned)
|
||||
return false;
|
||||
|
||||
_chatChannelService.RemovePresence(userUid);
|
||||
await NotifyChatBanAsync(userUid).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task NotifyChatBanAsync(string userUid)
|
||||
{
|
||||
if (string.Equals(userUid, UserUID, StringComparison.Ordinal))
|
||||
{
|
||||
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your chat access has been revoked.").ConfigureAwait(false);
|
||||
}
|
||||
else if (_userConnections.TryGetValue(userUid, out var connectionId))
|
||||
{
|
||||
await Clients.Client(connectionId).Client_ReceiveServerMessage(MessageSeverity.Error, "Your chat access has been revoked.").ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct ChatReportSnapshotItem(
|
||||
string MessageId,
|
||||
DateTime SentAtUtc,
|
||||
string SenderUserUid,
|
||||
string? SenderAlias,
|
||||
bool SenderIsLightfinder,
|
||||
string? SenderHashedCid,
|
||||
string Message);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Data.Enum;
|
||||
using LightlessSync.API.Dto;
|
||||
using LightlessSync.API.Dto.Chat;
|
||||
using LightlessSync.API.Dto.CharaData;
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using LightlessSync.API.Dto.User;
|
||||
@@ -38,5 +39,6 @@ namespace LightlessSyncServer.Hubs
|
||||
public Task Client_GposeLobbyPushCharacterData(CharaDataDownloadDto charaDownloadDto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
|
||||
public Task Client_GposeLobbyPushPoseData(UserData userData, PoseData poseData) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
|
||||
public Task Client_GposeLobbyPushWorldData(UserData userData, WorldData worldData) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
|
||||
public Task Client_ChatReceive(ChatMessageDto message) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Data.Enum;
|
||||
using LightlessSync.API.Dto;
|
||||
using LightlessSync.API.Dto.Chat;
|
||||
using LightlessSync.API.SignalR;
|
||||
using LightlessSyncServer.Services;
|
||||
using LightlessSyncServer.Configuration;
|
||||
@@ -16,6 +17,8 @@ using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StackExchange.Redis.Extensions.Core.Abstractions;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace LightlessSyncServer.Hubs;
|
||||
|
||||
@@ -43,6 +46,7 @@ public partial class LightlessHub : Hub<ILightlessHub>, ILightlessHub
|
||||
private LightlessDbContext DbContext => _dbContextLazy.Value;
|
||||
private readonly int _maxCharaDataByUser;
|
||||
private readonly int _maxCharaDataByUserVanity;
|
||||
private readonly ChatChannelService _chatChannelService;
|
||||
|
||||
private CancellationToken RequestAbortedToken => _contextAccessor.HttpContext?.RequestAborted ?? Context?.ConnectionAborted ?? CancellationToken.None;
|
||||
|
||||
@@ -50,7 +54,8 @@ public partial class LightlessHub : Hub<ILightlessHub>, ILightlessHub
|
||||
IDbContextFactory<LightlessDbContext> lightlessDbContextFactory, ILogger<LightlessHub> logger, SystemInfoService systemInfoService,
|
||||
IConfigurationService<ServerConfiguration> configuration, IHttpContextAccessor contextAccessor,
|
||||
IRedisDatabase redisDb, OnlineSyncedPairCacheService onlineSyncedPairCacheService, LightlessCensus lightlessCensus,
|
||||
GPoseLobbyDistributionService gPoseLobbyDistributionService, IBroadcastConfiguration broadcastConfiguration, PairService pairService)
|
||||
GPoseLobbyDistributionService gPoseLobbyDistributionService, IBroadcastConfiguration broadcastConfiguration, PairService pairService,
|
||||
ChatChannelService chatChannelService)
|
||||
{
|
||||
_lightlessMetrics = lightlessMetrics;
|
||||
_systemInfoService = systemInfoService;
|
||||
@@ -71,6 +76,7 @@ public partial class LightlessHub : Hub<ILightlessHub>, ILightlessHub
|
||||
_dbContextLazy = new Lazy<LightlessDbContext>(() => lightlessDbContextFactory.CreateDbContext());
|
||||
_broadcastConfiguration = broadcastConfiguration;
|
||||
_pairService = pairService;
|
||||
_chatChannelService = chatChannelService;
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
@@ -221,6 +227,7 @@ public partial class LightlessHub : Hub<ILightlessHub>, ILightlessHub
|
||||
catch { }
|
||||
finally
|
||||
{
|
||||
_chatChannelService.RemovePresence(UserUID);
|
||||
_userConnections.Remove(UserUID, out _);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Dto.Chat;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace LightlessSyncServer.Services;
|
||||
|
||||
public sealed class ChatChannelService
|
||||
{
|
||||
private readonly ILogger<ChatChannelService> _logger;
|
||||
private readonly Dictionary<string, ZoneChannelDefinition> _zoneDefinitions = new(StringComparer.OrdinalIgnoreCase);
|
||||
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 = 40;
|
||||
|
||||
public ChatChannelService(ILogger<ChatChannelService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
|
||||
AddZoneDefinition(new ZoneChannelDefinition(
|
||||
Key: "limsa",
|
||||
DisplayName: "Limsa Lominsa",
|
||||
Descriptor: new ChatChannelDescriptor
|
||||
{
|
||||
Type = ChatChannelType.Zone,
|
||||
WorldId = 0,
|
||||
ZoneId = 0,
|
||||
CustomKey = "limsa"
|
||||
},
|
||||
TerritoryNames: new[]
|
||||
{
|
||||
"Limsa Lominsa Lower Decks",
|
||||
"Limsa Lominsa Upper Decks"
|
||||
}));
|
||||
|
||||
AddZoneDefinition(new ZoneChannelDefinition(
|
||||
Key: "gridania",
|
||||
DisplayName: "Gridania",
|
||||
Descriptor: new ChatChannelDescriptor
|
||||
{
|
||||
Type = ChatChannelType.Zone,
|
||||
WorldId = 0,
|
||||
ZoneId = 0,
|
||||
CustomKey = "gridania"
|
||||
},
|
||||
TerritoryNames: new[]
|
||||
{
|
||||
"New Gridania",
|
||||
"Old Gridania"
|
||||
}));
|
||||
|
||||
AddZoneDefinition(new ZoneChannelDefinition(
|
||||
Key: "uldah",
|
||||
DisplayName: "Ul'dah",
|
||||
Descriptor: new ChatChannelDescriptor
|
||||
{
|
||||
Type = ChatChannelType.Zone,
|
||||
WorldId = 0,
|
||||
ZoneId = 0,
|
||||
CustomKey = "uldah"
|
||||
},
|
||||
TerritoryNames: new[]
|
||||
{
|
||||
"Ul'dah - Steps of Nald",
|
||||
"Ul'dah - Steps of Thal"
|
||||
}));
|
||||
}
|
||||
|
||||
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,
|
||||
string? hashedCid,
|
||||
bool isLightfinder,
|
||||
bool isActive)
|
||||
{
|
||||
var descriptor = definition.Descriptor with { WorldId = worldId };
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
if (_messagesByChannel.TryGetValue(key, out var messages))
|
||||
{
|
||||
foreach (var entry in messages)
|
||||
{
|
||||
_messageIndex.Remove(entry.MessageId);
|
||||
}
|
||||
|
||||
_messagesByChannel.Remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_participantsByChannel.TryGetValue(key, out var participants))
|
||||
{
|
||||
participants.Remove(existing.Participant.Token);
|
||||
if (participants.Count == 0)
|
||||
{
|
||||
_participantsByChannel.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void AddZoneDefinition(ZoneChannelDefinition definition)
|
||||
{
|
||||
_zoneDefinitions[definition.Key] = definition;
|
||||
}
|
||||
|
||||
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 static string NormalizeKey(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToUpperInvariant();
|
||||
|
||||
public readonly record struct ChatPresenceEntry(
|
||||
ChatChannelDescriptor Channel,
|
||||
ChannelKey ChannelKey,
|
||||
string DisplayName,
|
||||
ChatParticipantInfo Participant,
|
||||
DateTime UpdatedAt);
|
||||
|
||||
public readonly record struct ChatParticipantInfo(
|
||||
string Token,
|
||||
string UserUid,
|
||||
UserData? User,
|
||||
string? HashedCid,
|
||||
bool IsLightfinder);
|
||||
|
||||
public readonly record struct ChatMessageLogEntry(
|
||||
string MessageId,
|
||||
ChatChannelDescriptor Channel,
|
||||
DateTime SentAtUtc,
|
||||
string SenderToken,
|
||||
string SenderUserUid,
|
||||
UserData? SenderUser,
|
||||
bool SenderIsLightfinder,
|
||||
string? SenderHashedCid,
|
||||
string Message);
|
||||
|
||||
public readonly record struct ZoneChannelDefinition(
|
||||
string Key,
|
||||
string DisplayName,
|
||||
ChatChannelDescriptor Descriptor,
|
||||
IReadOnlyList<string> TerritoryNames);
|
||||
|
||||
public readonly record struct ChannelKey(ChatChannelType Type, ushort WorldId, string CustomKey)
|
||||
{
|
||||
public static ChannelKey FromDescriptor(ChatChannelDescriptor descriptor)
|
||||
=> new(
|
||||
descriptor.Type,
|
||||
descriptor.Type == ChatChannelType.Zone ? descriptor.WorldId : (ushort)0,
|
||||
NormalizeKey(descriptor.CustomKey));
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,7 @@ public class Startup
|
||||
services.AddSingleton<ServerTokenGenerator>();
|
||||
services.AddSingleton<SystemInfoService>();
|
||||
services.AddSingleton<OnlineSyncedPairCacheService>();
|
||||
services.AddSingleton<ChatChannelService>();
|
||||
services.AddHostedService(provider => provider.GetService<SystemInfoService>());
|
||||
// configure services based on main server status
|
||||
ConfigureServicesBasedOnShardType(services, lightlessConfig, isMainServer);
|
||||
|
||||
Reference in New Issue
Block a user