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

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