chat
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user