This commit is contained in:
azyges
2025-10-29 07:50:41 +09:00
parent ee69df8081
commit dceaceb941
14 changed files with 3108 additions and 5 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,9 @@
using System.Collections.Generic;
using System;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Linq;
using Discord;
using Discord.Interactions;
using Discord.Rest;
@@ -5,6 +11,7 @@ using Discord.WebSocket;
using LightlessSyncShared.Data;
using LightlessSyncShared.Models;
using LightlessSyncShared.Services;
using LightlessSync.API.Dto.Chat;
using LightlessSyncShared.Utils.Configuration;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis;
@@ -13,6 +20,12 @@ namespace LightlessSyncServices.Discord;
internal class DiscordBot : IHostedService
{
private static readonly JsonSerializerOptions ChatReportSerializerOptions = new(JsonSerializerDefaults.General)
{
PropertyNameCaseInsensitive = true
};
private const string ChatReportButtonPrefix = "lightless-chat-report-button";
private readonly DiscordBotServices _botServices;
private readonly IConfigurationService<ServicesConfiguration> _configurationService;
private readonly IConnectionMultiplexer _connectionMultiplexer;
@@ -21,7 +34,7 @@ internal class DiscordBot : IHostedService
private readonly IDbContextFactory<LightlessDbContext> _dbContextFactory;
private readonly IServiceProvider _services;
private InteractionService _interactionModule;
private readonly CancellationTokenSource? _processReportQueueCts;
private CancellationTokenSource? _chatReportProcessingCts;
private CancellationTokenSource? _clientConnectedCts;
public DiscordBot(DiscordBotServices botServices, IServiceProvider services, IConfigurationService<ServicesConfiguration> configuration,
@@ -66,6 +79,7 @@ internal class DiscordBot : IHostedService
var ctx = new SocketInteractionContext(_discordClient, x);
await _interactionModule.ExecuteCommandAsync(ctx, _services).ConfigureAwait(false);
};
_discordClient.ButtonExecuted += OnChatReportButton;
_discordClient.UserJoined += OnUserJoined;
await _botServices.Start().ConfigureAwait(false);
@@ -94,9 +108,11 @@ internal class DiscordBot : IHostedService
if (!string.IsNullOrEmpty(_configurationService.GetValueOrDefault(nameof(ServicesConfiguration.DiscordBotToken), string.Empty)))
{
await _botServices.Stop().ConfigureAwait(false);
_processReportQueueCts?.Cancel();
_chatReportProcessingCts?.Cancel();
_chatReportProcessingCts?.Dispose();
_clientConnectedCts?.Cancel();
_discordClient.ButtonExecuted -= OnChatReportButton;
await _discordClient.LogoutAsync().ConfigureAwait(false);
await _discordClient.StopAsync().ConfigureAwait(false);
_interactionModule?.Dispose();
@@ -112,6 +128,13 @@ internal class DiscordBot : IHostedService
_clientConnectedCts = new();
_ = UpdateStatusAsync(_clientConnectedCts.Token);
_chatReportProcessingCts?.Cancel();
_chatReportProcessingCts?.Dispose();
_chatReportProcessingCts = new();
_ = PollChatReportsAsync(_chatReportProcessingCts.Token);
await PublishChatReportsAsync(CancellationToken.None).ConfigureAwait(false);
await CreateOrUpdateModal(guild).ConfigureAwait(false);
_botServices.UpdateGuild(guild);
await _botServices.LogToChannel("Bot startup complete.").ConfigureAwait(false);
@@ -120,6 +143,358 @@ internal class DiscordBot : IHostedService
_ = RemoveUnregisteredUsers(_clientConnectedCts.Token);
}
private async Task PollChatReportsAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
await PublishChatReportsAsync(token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed while polling chat reports");
}
try
{
await Task.Delay(TimeSpan.FromMinutes(10), token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
}
private async Task PublishChatReportsAsync(CancellationToken token)
{
var reportChannelId = _configurationService.GetValueOrDefault(nameof(ServicesConfiguration.DiscordChannelForChatReports), (ulong?)null);
if (reportChannelId is null)
{
return;
}
var channel = await _discordClient.Rest.GetChannelAsync(reportChannelId.Value).ConfigureAwait(false) as RestTextChannel;
if (channel is null)
{
_logger.LogWarning("Configured chat report channel {ChannelId} could not be resolved.", reportChannelId);
return;
}
using var dbContext = await _dbContextFactory.CreateDbContextAsync(token).ConfigureAwait(false);
var pendingReports = await dbContext.ReportedChatMessages
.Where(r => !r.Resolved && r.DiscordMessageId == null)
.OrderBy(r => r.ReportTimeUtc)
.Take(10)
.ToListAsync(token)
.ConfigureAwait(false);
if (pendingReports.Count == 0)
{
return;
}
foreach (var report in pendingReports)
{
var embed = await BuildChatReportEmbedAsync(dbContext, report, token).ConfigureAwait(false);
var components = new ComponentBuilder()
.WithButton("Actioned", $"{ChatReportButtonPrefix}-resolve-{report.ReportId}", ButtonStyle.Danger)
.WithButton("Dismiss", $"{ChatReportButtonPrefix}-dismiss-{report.ReportId}", ButtonStyle.Secondary)
.WithButton("Ban From Chat", $"{ChatReportButtonPrefix}-banchat-{report.ReportId}", ButtonStyle.Danger);
var postedMessage = await channel.SendMessageAsync(embed: embed.Build(), components: components.Build()).ConfigureAwait(false);
report.DiscordMessageId = postedMessage.Id;
report.DiscordMessagePostedAtUtc = DateTime.UtcNow;
}
await dbContext.SaveChangesAsync(token).ConfigureAwait(false);
}
private async Task<EmbedBuilder> BuildChatReportEmbedAsync(LightlessDbContext dbContext, ReportedChatMessage report, CancellationToken token)
{
var reporter = await FormatUserForEmbedAsync(dbContext, report.ReporterUserUid, token).ConfigureAwait(false);
var reportedUser = await FormatUserForEmbedAsync(dbContext, report.ReportedUserUid, token).ConfigureAwait(false);
var channelDescription = await DescribeChannelAsync(dbContext, report, token).ConfigureAwait(false);
var embed = new EmbedBuilder()
.WithTitle("Chat Report")
.WithColor(Color.DarkTeal)
.WithTimestamp(report.ReportTimeUtc)
.AddField("Report ID", report.ReportId, inline: true)
.AddField("Reporter", reporter, inline: true)
.AddField("Reported User", string.IsNullOrEmpty(reportedUser) ? "-" : reportedUser, inline: true)
.AddField("Channel", channelDescription, inline: false)
.AddField("Reason", string.IsNullOrWhiteSpace(report.Reason) ? "-" : report.Reason);
if (!string.IsNullOrWhiteSpace(report.AdditionalContext))
{
embed.AddField("Additional Context", report.AdditionalContext);
}
embed.AddField("Message", $"```{Truncate(report.MessageContent, 1000)}```");
var snapshotPreview = BuildSnapshotPreview(report.SnapshotJson);
if (!string.IsNullOrEmpty(snapshotPreview))
{
embed.AddField("Recent Activity", snapshotPreview);
}
embed.WithFooter($"Message ID: {report.MessageId}");
return embed;
}
private async Task<string> DescribeChannelAsync(LightlessDbContext dbContext, ReportedChatMessage report, CancellationToken token)
{
if (report.ChannelType == ChatChannelType.Group)
{
if (!string.IsNullOrEmpty(report.ChannelKey))
{
var group = await dbContext.Groups.AsNoTracking()
.SingleOrDefaultAsync(g => g.GID == report.ChannelKey, token)
.ConfigureAwait(false);
if (group != null)
{
var name = string.IsNullOrWhiteSpace(group.Alias) ? group.GID : group.Alias;
return $"Group: {name} ({group.GID})";
}
}
return $"Group: {report.ChannelKey ?? "unknown"}";
}
return $"Zone: {report.ChannelKey ?? "unknown"} (World {report.WorldId}, Zone {report.ZoneId})";
}
private async Task<string> FormatUserForEmbedAsync(LightlessDbContext dbContext, string? userUid, CancellationToken token)
{
if (string.IsNullOrEmpty(userUid))
{
return "-";
}
var user = await dbContext.Users.AsNoTracking()
.SingleOrDefaultAsync(u => u.UID == userUid, token)
.ConfigureAwait(false);
var display = user?.Alias ?? user?.UID ?? userUid;
var lodestone = await dbContext.LodeStoneAuth
.Include(l => l.User)
.AsNoTracking()
.SingleOrDefaultAsync(l => l.User != null && l.User.UID == userUid, token)
.ConfigureAwait(false);
if (lodestone != null)
{
display = $"{display} (<@{lodestone.DiscordId}>)";
}
return display;
}
private string BuildSnapshotPreview(string snapshotJson)
{
if (string.IsNullOrWhiteSpace(snapshotJson))
{
return string.Empty;
}
try
{
var snapshot = JsonSerializer.Deserialize<List<ChatReportSnapshotItem>>(snapshotJson, ChatReportSerializerOptions);
if (snapshot is null || snapshot.Count == 0)
{
return string.Empty;
}
var builder = new StringBuilder();
foreach (var item in snapshot.TakeLast(5))
{
var sender = item.SenderAlias ?? item.SenderUserUid;
builder.AppendLine($"{item.SentAtUtc:HH\\:mm} {sender}: {Truncate(item.Message, 120)}");
}
return $"```{builder.ToString().TrimEnd()}```";
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse chat report snapshot");
return string.Empty;
}
}
private static string Truncate(string value, int maxLength)
{
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
{
return value;
}
return value[..maxLength] + "...";
}
private async Task OnChatReportButton(SocketMessageComponent arg)
{
if (!arg.Data.CustomId.StartsWith(ChatReportButtonPrefix, StringComparison.Ordinal))
{
return;
}
if (arg.GuildId is null)
{
await arg.RespondAsync("This action is only available inside the server.", ephemeral: true).ConfigureAwait(false);
return;
}
var guild = _discordClient.GetGuild(arg.GuildId.Value);
if (guild is null)
{
await arg.RespondAsync("Unable to resolve the guild for this interaction.", ephemeral: true).ConfigureAwait(false);
return;
}
var guildUser = guild.GetUser(arg.User.Id);
if (guildUser is null || !(guildUser.GuildPermissions.ManageMessages || guildUser.GuildPermissions.BanMembers || guildUser.GuildPermissions.Administrator))
{
await arg.RespondAsync("You do not have permission to resolve chat reports.", ephemeral: true).ConfigureAwait(false);
return;
}
var parts = arg.Data.CustomId.Split('-', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 5 || !int.TryParse(parts[^1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var reportId))
{
await arg.RespondAsync("Invalid report action.", ephemeral: true).ConfigureAwait(false);
return;
}
var action = parts[^2];
await using var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
var report = await dbContext.ReportedChatMessages.SingleOrDefaultAsync(r => r.ReportId == reportId).ConfigureAwait(false);
if (report is null)
{
await arg.RespondAsync("This report could not be found.", ephemeral: true).ConfigureAwait(false);
return;
}
if (report.Resolved)
{
await arg.RespondAsync("This report has already been processed.", ephemeral: true).ConfigureAwait(false);
return;
}
string resolutionLabel;
switch (action)
{
case "resolve":
resolutionLabel = "Actioned";
break;
case "dismiss":
resolutionLabel = "Dismissed";
break;
case "banchat":
resolutionLabel = "Chat access revoked";
if (!string.IsNullOrEmpty(report.ReportedUserUid))
{
var targetUser = await dbContext.Users.SingleOrDefaultAsync(u => u.UID == report.ReportedUserUid).ConfigureAwait(false);
if (targetUser is not null && !targetUser.ChatBanned)
{
targetUser.ChatBanned = true;
dbContext.Update(targetUser);
}
}
break;
default:
await arg.RespondAsync("Unknown action.", ephemeral: true).ConfigureAwait(false);
return;
}
try
{
await UpdateChatReportMessageAsync(report, action, guildUser).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to update Discord message for resolved report {ReportId}", report.ReportId);
}
dbContext.ReportedChatMessages.Remove(report);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
string responseText = action switch
{
"resolve" => "actioned",
"dismiss" => "dismissed",
"banchat" => "chat access revoked",
_ => "processed"
};
await arg.RespondAsync($"Report {report.ReportId} {responseText}.", ephemeral: true).ConfigureAwait(false);
}
private async Task UpdateChatReportMessageAsync(ReportedChatMessage report, string action, SocketGuildUser moderator)
{
if (report.DiscordMessageId is null)
{
return;
}
var reportChannelId = _configurationService.GetValueOrDefault(nameof(ServicesConfiguration.DiscordChannelForChatReports), (ulong?)null);
if (reportChannelId is null)
{
return;
}
var channel = await _discordClient.Rest.GetChannelAsync(reportChannelId.Value).ConfigureAwait(false) as RestTextChannel;
if (channel is null)
{
return;
}
var message = await channel.GetMessageAsync(report.DiscordMessageId.Value).ConfigureAwait(false) as IUserMessage;
if (message is null)
{
return;
}
var existingEmbed = message.Embeds.FirstOrDefault();
var embedBuilder = existingEmbed is Embed richEmbed
? richEmbed.ToEmbedBuilder()
: new EmbedBuilder().WithTitle("Chat Report");
embedBuilder.Fields.RemoveAll(f => string.Equals(f.Name, "Resolution", StringComparison.OrdinalIgnoreCase));
var resolutionText = action switch
{
"resolve" => "Actioned",
"dismiss" => "Dismissed",
"banchat" => "Chat access revoked",
_ => "Processed"
};
var resolutionColor = action switch
{
"resolve" => Color.DarkRed,
"dismiss" => Color.Green,
"banchat" => Color.DarkRed,
_ => Color.LightGrey
};
embedBuilder.AddField("Resolution", $"{resolutionText} by {moderator.Mention} at <t:{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}:F>");
embedBuilder.WithColor(resolutionColor);
await message.ModifyAsync(props =>
{
props.Embed = embedBuilder.Build();
props.Components = new ComponentBuilder().Build();
}).ConfigureAwait(false);
}
private async Task UpdateVanityRoles(RestGuild guild, CancellationToken token)
{
while (!token.IsCancellationRequested)
@@ -488,6 +863,15 @@ internal class DiscordBot : IHostedService
}
}
private sealed record ChatReportSnapshotItem(
string MessageId,
DateTime SentAtUtc,
string SenderUserUid,
string? SenderAlias,
bool SenderIsLightfinder,
string? SenderHashedCid,
string Message);
private async Task UpdateStatusAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
@@ -500,4 +884,4 @@ internal class DiscordBot : IHostedService
await Task.Delay(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
}
}
}
}

View File

@@ -45,6 +45,7 @@ public class LightlessDbContext : DbContext
public DbSet<UserProfileData> UserProfileData { get; set; }
public DbSet<User> Users { get; set; }
public DbSet<UserPermissionSet> Permissions { get; set; }
public DbSet<ReportedChatMessage> ReportedChatMessages { get; set; }
public DbSet<GroupPairPreferredPermission> GroupPairPreferredPermissions { get; set; }
public DbSet<UserDefaultPreferredPermission> UserDefaultPreferredPermissions { get; set; }
public DbSet<CharaData> CharaData { get; set; }
@@ -153,5 +154,11 @@ public class LightlessDbContext : DbContext
mb.Entity<CharaDataAllowance>().HasIndex(c => c.ParentId);
mb.Entity<CharaDataAllowance>().HasOne(u => u.AllowedGroup).WithMany().HasForeignKey(u => u.AllowedGroupGID).OnDelete(DeleteBehavior.Cascade);
mb.Entity<CharaDataAllowance>().HasOne(u => u.AllowedUser).WithMany().HasForeignKey(u => u.AllowedUserUID).OnDelete(DeleteBehavior.Cascade);
mb.Entity<ReportedChatMessage>().ToTable("reported_chat_messages");
mb.Entity<ReportedChatMessage>().HasIndex(r => r.ReporterUserUid);
mb.Entity<ReportedChatMessage>().HasIndex(r => r.ReportedUserUid);
mb.Entity<ReportedChatMessage>().HasIndex(r => r.MessageId).IsUnique();
mb.Entity<ReportedChatMessage>().HasIndex(r => r.DiscordMessageId);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,90 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace LightlessSyncServer.Migrations
{
/// <inheritdoc />
public partial class ChatReports : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "reported_chat_messages",
columns: table => new
{
report_id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
report_time_utc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
reporter_user_uid = table.Column<string>(type: "text", nullable: false),
reported_user_uid = table.Column<string>(type: "text", nullable: true),
channel_type = table.Column<byte>(type: "smallint", nullable: false),
world_id = table.Column<int>(type: "integer", nullable: false),
zone_id = table.Column<int>(type: "integer", nullable: false),
channel_key = table.Column<string>(type: "text", nullable: false),
message_id = table.Column<string>(type: "text", nullable: false),
message_sent_at_utc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
message_content = table.Column<string>(type: "text", nullable: false),
sender_token = table.Column<string>(type: "text", nullable: false),
sender_hashed_cid = table.Column<string>(type: "text", nullable: true),
sender_display_name = table.Column<string>(type: "text", nullable: true),
sender_was_lightfinder = table.Column<bool>(type: "boolean", nullable: false),
snapshot_json = table.Column<string>(type: "text", nullable: true),
reason = table.Column<string>(type: "text", nullable: true),
additional_context = table.Column<string>(type: "text", nullable: true),
discord_message_id = table.Column<decimal>(type: "numeric(20,0)", nullable: true),
discord_message_posted_at_utc = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
resolved = table.Column<bool>(type: "boolean", nullable: false),
resolved_at_utc = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
resolution_notes = table.Column<string>(type: "text", nullable: true),
resolved_by_user_uid = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_reported_chat_messages", x => x.report_id);
});
migrationBuilder.AddColumn<bool>(
name: "chat_banned",
table: "users",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.CreateIndex(
name: "ix_reported_chat_messages_discord_message_id",
table: "reported_chat_messages",
column: "discord_message_id");
migrationBuilder.CreateIndex(
name: "ix_reported_chat_messages_message_id",
table: "reported_chat_messages",
column: "message_id",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_reported_chat_messages_reported_user_uid",
table: "reported_chat_messages",
column: "reported_user_uid");
migrationBuilder.CreateIndex(
name: "ix_reported_chat_messages_reporter_user_uid",
table: "reported_chat_messages",
column: "reporter_user_uid");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "reported_chat_messages");
migrationBuilder.DropColumn(
name: "chat_banned",
table: "users");
}
}
}

View File

@@ -683,6 +683,131 @@ namespace LightlessSyncServer.Migrations
b.ToTable("lodestone_auth", (string)null);
});
modelBuilder.Entity("LightlessSyncShared.Models.ReportedChatMessage", b =>
{
b.Property<int>("ReportId")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("report_id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ReportId"));
b.Property<string>("AdditionalContext")
.HasColumnType("text")
.HasColumnName("additional_context");
b.Property<string>("ChannelKey")
.IsRequired()
.HasColumnType("text")
.HasColumnName("channel_key");
b.Property<byte>("ChannelType")
.HasColumnType("smallint")
.HasColumnName("channel_type");
b.Property<decimal?>("DiscordMessageId")
.HasColumnType("numeric(20,0)")
.HasColumnName("discord_message_id");
b.Property<DateTime?>("DiscordMessagePostedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("discord_message_posted_at_utc");
b.Property<string>("MessageContent")
.IsRequired()
.HasColumnType("text")
.HasColumnName("message_content");
b.Property<string>("MessageId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("message_id");
b.Property<DateTime>("MessageSentAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("message_sent_at_utc");
b.Property<string>("Reason")
.HasColumnType("text")
.HasColumnName("reason");
b.Property<DateTime>("ReportTimeUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("report_time_utc");
b.Property<string>("ReportedUserUid")
.HasColumnType("text")
.HasColumnName("reported_user_uid");
b.Property<string>("ReporterUserUid")
.IsRequired()
.HasColumnType("text")
.HasColumnName("reporter_user_uid");
b.Property<string>("ResolutionNotes")
.HasColumnType("text")
.HasColumnName("resolution_notes");
b.Property<bool>("Resolved")
.HasColumnType("boolean")
.HasColumnName("resolved");
b.Property<DateTime?>("ResolvedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("resolved_at_utc");
b.Property<string>("ResolvedByUserUid")
.HasColumnType("text")
.HasColumnName("resolved_by_user_uid");
b.Property<string>("SenderDisplayName")
.HasColumnType("text")
.HasColumnName("sender_display_name");
b.Property<string>("SenderHashedCid")
.HasColumnType("text")
.HasColumnName("sender_hashed_cid");
b.Property<string>("SenderToken")
.IsRequired()
.HasColumnType("text")
.HasColumnName("sender_token");
b.Property<bool>("SenderWasLightfinder")
.HasColumnType("boolean")
.HasColumnName("sender_was_lightfinder");
b.Property<string>("SnapshotJson")
.HasColumnType("text")
.HasColumnName("snapshot_json");
b.Property<int>("WorldId")
.HasColumnType("integer")
.HasColumnName("world_id");
b.Property<int>("ZoneId")
.HasColumnType("integer")
.HasColumnName("zone_id");
b.HasKey("ReportId")
.HasName("pk_reported_chat_messages");
b.HasIndex("DiscordMessageId")
.HasDatabaseName("ix_reported_chat_messages_discord_message_id");
b.HasIndex("MessageId")
.IsUnique()
.HasDatabaseName("ix_reported_chat_messages_message_id");
b.HasIndex("ReportedUserUid")
.HasDatabaseName("ix_reported_chat_messages_reported_user_uid");
b.HasIndex("ReporterUserUid")
.HasDatabaseName("ix_reported_chat_messages_reporter_user_uid");
b.ToTable("reported_chat_messages", (string)null);
});
modelBuilder.Entity("LightlessSyncShared.Models.User", b =>
{
b.Property<string>("UID")
@@ -699,6 +824,10 @@ namespace LightlessSyncServer.Migrations
.HasColumnType("boolean")
.HasColumnName("has_vanity");
b.Property<bool>("ChatBanned")
.HasColumnType("boolean")
.HasColumnName("chat_banned");
b.Property<bool>("IsAdmin")
.HasColumnType("boolean")
.HasColumnName("is_admin");

View File

@@ -0,0 +1,69 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using LightlessSync.API.Dto.Chat;
namespace LightlessSyncShared.Models;
/// <summary>
/// Stores metadata about chat reports submitted by users.
/// </summary>
public class ReportedChatMessage
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int ReportId { get; set; }
[Required]
public DateTime ReportTimeUtc { get; set; }
[Required]
public string ReporterUserUid { get; set; } = string.Empty;
public string? ReportedUserUid { get; set; }
[Required]
public ChatChannelType ChannelType { get; set; }
public ushort WorldId { get; set; }
public ushort ZoneId { get; set; }
[Required]
public string ChannelKey { get; set; } = string.Empty;
[Required]
public string MessageId { get; set; } = string.Empty;
public DateTime MessageSentAtUtc { get; set; }
[Required]
public string MessageContent { get; set; } = string.Empty;
[Required]
public string SenderToken { get; set; } = string.Empty;
public string? SenderHashedCid { get; set; }
public string? SenderDisplayName { get; set; }
public bool SenderWasLightfinder { get; set; }
public string SnapshotJson { get; set; } = string.Empty;
public string? Reason { get; set; }
public string? AdditionalContext { get; set; }
public ulong? DiscordMessageId { get; set; }
public DateTime? DiscordMessagePostedAtUtc { get; set; }
public bool Resolved { get; set; }
public DateTime? ResolvedAtUtc { get; set; }
public string? ResolutionNotes { get; set; }
public string? ResolvedByUserUid { get; set; }
}

View File

@@ -22,6 +22,8 @@ public class User
[MaxLength(9)]
public string? TextGlowColorHex { get; set; } = string.Empty;
public bool ChatBanned { get; set; } = false;
public DateTime LastLoggedIn { get; set; }
[MaxLength(15)]
public string Alias { get; set; }

View File

@@ -7,6 +7,7 @@ public class ServicesConfiguration : LightlessConfigurationBase
public string DiscordBotToken { get; set; } = string.Empty;
public ulong? DiscordChannelForMessages { get; set; } = null;
public ulong? DiscordChannelForCommands { get; set; } = null;
public ulong? DiscordChannelForChatReports { get; set; } = null;
public ulong? DiscordRoleAprilFools2024 { get; set; } = null;
public ulong? DiscordChannelForBotLog { get; set; } = null!;
public ulong? DiscordRoleRegistered { get; set; } = null!;
@@ -22,6 +23,7 @@ public class ServicesConfiguration : LightlessConfigurationBase
sb.AppendLine($"{nameof(MainServerAddress)} => {MainServerAddress}");
sb.AppendLine($"{nameof(DiscordChannelForMessages)} => {DiscordChannelForMessages}");
sb.AppendLine($"{nameof(DiscordChannelForCommands)} => {DiscordChannelForCommands}");
sb.AppendLine($"{nameof(DiscordChannelForChatReports)} => {DiscordChannelForChatReports}");
sb.AppendLine($"{nameof(DiscordRoleAprilFools2024)} => {DiscordRoleAprilFools2024}");
sb.AppendLine($"{nameof(DiscordRoleRegistered)} => {DiscordRoleRegistered}");
sb.AppendLine($"{nameof(KickNonRegisteredUsers)} => {KickNonRegisteredUsers}");