Co-authored-by: defnotken <itsdefnotken@gmail.com> Reviewed-on: #39 Co-authored-by: defnotken <defnotken@noreply.git.lightless-sync.org> Co-committed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
886 lines
36 KiB
C#
886 lines
36 KiB
C#
using System.Globalization;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Discord;
|
|
using Discord.Interactions;
|
|
using Discord.Rest;
|
|
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;
|
|
|
|
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;
|
|
private readonly DiscordSocketClient _discordClient;
|
|
private readonly ILogger<DiscordBot> _logger;
|
|
private readonly IDbContextFactory<LightlessDbContext> _dbContextFactory;
|
|
private readonly IServiceProvider _services;
|
|
private InteractionService _interactionModule;
|
|
private CancellationTokenSource? _chatReportProcessingCts;
|
|
private CancellationTokenSource? _clientConnectedCts;
|
|
|
|
public DiscordBot(DiscordBotServices botServices, IServiceProvider services, IConfigurationService<ServicesConfiguration> configuration,
|
|
IDbContextFactory<LightlessDbContext> dbContextFactory,
|
|
ILogger<DiscordBot> logger, IConnectionMultiplexer connectionMultiplexer)
|
|
{
|
|
_botServices = botServices;
|
|
_services = services;
|
|
_configurationService = configuration;
|
|
_dbContextFactory = dbContextFactory;
|
|
_logger = logger;
|
|
_connectionMultiplexer = connectionMultiplexer;
|
|
_discordClient = new(new DiscordSocketConfig()
|
|
{
|
|
DefaultRetryMode = RetryMode.AlwaysRetry,
|
|
GatewayIntents = GatewayIntents.AllUnprivileged | GatewayIntents.GuildMembers
|
|
});
|
|
|
|
_discordClient.Log += Log;
|
|
}
|
|
|
|
public async Task StartAsync(CancellationToken cancellationToken)
|
|
{
|
|
var token = _configurationService.GetValueOrDefault(nameof(ServicesConfiguration.DiscordBotToken), string.Empty);
|
|
if (!string.IsNullOrEmpty(token))
|
|
{
|
|
_logger.LogInformation("Starting DiscordBot");
|
|
_logger.LogInformation("Using Configuration: " + _configurationService.ToString());
|
|
|
|
_interactionModule?.Dispose();
|
|
_interactionModule = new InteractionService(_discordClient);
|
|
_interactionModule.Log += Log;
|
|
await _interactionModule.AddModuleAsync(typeof(LightlessModule), _services).ConfigureAwait(false);
|
|
await _interactionModule.AddModuleAsync(typeof(LightlessWizardModule), _services).ConfigureAwait(false);
|
|
|
|
await _discordClient.LoginAsync(TokenType.Bot, token).ConfigureAwait(false);
|
|
await _discordClient.StartAsync().ConfigureAwait(false);
|
|
|
|
_discordClient.Ready += DiscordClient_Ready;
|
|
_discordClient.InteractionCreated += async (x) =>
|
|
{
|
|
var ctx = new SocketInteractionContext(_discordClient, x);
|
|
await _interactionModule.ExecuteCommandAsync(ctx, _services).ConfigureAwait(false);
|
|
};
|
|
_discordClient.ButtonExecuted += OnChatReportButton;
|
|
_discordClient.UserJoined += OnUserJoined;
|
|
|
|
await _botServices.Start().ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private async Task OnUserJoined(SocketGuildUser arg)
|
|
{
|
|
try
|
|
{
|
|
using LightlessDbContext dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
|
|
var alreadyRegistered = await dbContext.LodeStoneAuth.AnyAsync(u => u.DiscordId == arg.Id).ConfigureAwait(false);
|
|
if (alreadyRegistered)
|
|
{
|
|
await _botServices.AddRegisteredRoleAsync(arg).ConfigureAwait(false);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to set user role on join");
|
|
}
|
|
}
|
|
|
|
public async Task StopAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (!string.IsNullOrEmpty(_configurationService.GetValueOrDefault(nameof(ServicesConfiguration.DiscordBotToken), string.Empty)))
|
|
{
|
|
await _botServices.Stop().ConfigureAwait(false);
|
|
_chatReportProcessingCts?.Cancel();
|
|
_chatReportProcessingCts?.Dispose();
|
|
_clientConnectedCts?.Cancel();
|
|
|
|
_discordClient.ButtonExecuted -= OnChatReportButton;
|
|
await _discordClient.LogoutAsync().ConfigureAwait(false);
|
|
await _discordClient.StopAsync().ConfigureAwait(false);
|
|
_interactionModule?.Dispose();
|
|
}
|
|
}
|
|
|
|
private async Task DiscordClient_Ready()
|
|
{
|
|
var guild = (await _discordClient.Rest.GetGuildsAsync().ConfigureAwait(false)).First();
|
|
await _interactionModule.RegisterCommandsToGuildAsync(guild.Id, true).ConfigureAwait(false);
|
|
_clientConnectedCts?.Cancel();
|
|
_clientConnectedCts?.Dispose();
|
|
_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);
|
|
_ = UpdateVanityRoles(guild, _clientConnectedCts.Token);
|
|
_ = RemoveUsersNotInVanityRole(_clientConnectedCts.Token);
|
|
_ = 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(1), 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("Resolve", $"{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 = "Resolved";
|
|
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" => "resolved",
|
|
"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" => "Resolved",
|
|
"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)
|
|
{
|
|
try
|
|
{
|
|
_logger.LogInformation("Updating Vanity Roles");
|
|
Dictionary<ulong, string> vanityRoles = _configurationService.GetValueOrDefault(nameof(ServicesConfiguration.VanityRoles), new Dictionary<ulong, string>());
|
|
if (vanityRoles.Keys.Count != _botServices.VanityRoles.Count)
|
|
{
|
|
_botServices.VanityRoles.Clear();
|
|
foreach (var role in vanityRoles)
|
|
{
|
|
_logger.LogInformation("Adding Role: {id} => {desc}", role.Key, role.Value);
|
|
|
|
var restrole = guild.GetRole(role.Key);
|
|
if (restrole != null)
|
|
_botServices.VanityRoles[restrole] = role.Value;
|
|
}
|
|
}
|
|
|
|
await Task.Delay(TimeSpan.FromSeconds(30), token).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Error during UpdateVanityRoles");
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task CreateOrUpdateModal(RestGuild guild)
|
|
{
|
|
_logger.LogInformation("Creating Wizard: Getting Channel");
|
|
|
|
var discordChannelForCommands = _configurationService.GetValue<ulong?>(nameof(ServicesConfiguration.DiscordChannelForCommands));
|
|
if (discordChannelForCommands == null)
|
|
{
|
|
_logger.LogWarning("Creating Wizard: No channel configured");
|
|
return;
|
|
}
|
|
|
|
IUserMessage? message = null;
|
|
var socketchannel = await _discordClient.GetChannelAsync(discordChannelForCommands.Value).ConfigureAwait(false) as SocketTextChannel;
|
|
var pinnedMessages = await socketchannel.GetPinnedMessagesAsync().ConfigureAwait(false);
|
|
foreach (var msg in pinnedMessages)
|
|
{
|
|
_logger.LogInformation("Creating Wizard: Checking message id {id}, author is: {author}, hasEmbeds: {embeds}", msg.Id, msg.Author.Id, msg.Embeds.Any());
|
|
if (msg.Author.Id == _discordClient.CurrentUser.Id
|
|
&& msg.Embeds.Any())
|
|
{
|
|
message = await socketchannel.GetMessageAsync(msg.Id).ConfigureAwait(false) as IUserMessage;
|
|
break;
|
|
}
|
|
}
|
|
|
|
_logger.LogInformation("Creating Wizard: Found message id: {id}", message?.Id ?? 0);
|
|
|
|
await GenerateOrUpdateWizardMessage(socketchannel, message).ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task GenerateOrUpdateWizardMessage(SocketTextChannel channel, IUserMessage? prevMessage)
|
|
{
|
|
EmbedBuilder eb = new EmbedBuilder();
|
|
eb.WithTitle("Lightless Services Bot Interaction Service");
|
|
eb.WithDescription("Press \"Start\" to interact with this bot!" + Environment.NewLine + Environment.NewLine
|
|
+ "You can handle all of your Lightless account needs in this server through the easy to use interactive bot prompt. Just follow the instructions!");
|
|
eb.WithThumbnailUrl("https://raw.githubusercontent.com/Light-Public-Syncshells/LightlessSync/refs/heads/main/LightlessSync/icon.png");
|
|
var cb = new ComponentBuilder();
|
|
cb.WithButton("Start", style: ButtonStyle.Primary, customId: "wizard-captcha:true", emote: Emoji.Parse("➡️"));
|
|
if (prevMessage == null)
|
|
{
|
|
var msg = await channel.SendMessageAsync(embed: eb.Build(), components: cb.Build()).ConfigureAwait(false);
|
|
try
|
|
{
|
|
await msg.PinAsync().ConfigureAwait(false);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// swallow
|
|
}
|
|
}
|
|
else
|
|
{
|
|
await prevMessage.ModifyAsync(p =>
|
|
{
|
|
p.Embed = eb.Build();
|
|
p.Components = cb.Build();
|
|
}).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private Task Log(LogMessage msg)
|
|
{
|
|
switch (msg.Severity)
|
|
{
|
|
case LogSeverity.Critical:
|
|
case LogSeverity.Error:
|
|
_logger.LogError(msg.Exception, msg.Message); break;
|
|
case LogSeverity.Warning:
|
|
_logger.LogWarning(msg.Exception, msg.Message); break;
|
|
default:
|
|
_logger.LogInformation(msg.Message); break;
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private async Task RemoveUnregisteredUsers(CancellationToken token)
|
|
{
|
|
var guild = (await _discordClient.Rest.GetGuildsAsync().ConfigureAwait(false)).First();
|
|
while (!token.IsCancellationRequested)
|
|
{
|
|
try
|
|
{
|
|
await ProcessUserRoles(guild, token).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// do nothing
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
await _botServices.LogToChannel($"Error during user procesing: {ex.Message}").ConfigureAwait(false);
|
|
}
|
|
|
|
await Task.Delay(TimeSpan.FromDays(1)).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private async Task ProcessUserRoles(RestGuild guild, CancellationToken token)
|
|
{
|
|
using LightlessDbContext dbContext = await _dbContextFactory.CreateDbContextAsync(token).ConfigureAwait(false);
|
|
var roleId = _configurationService.GetValueOrDefault<ulong?>(nameof(ServicesConfiguration.DiscordRoleRegistered), 0);
|
|
var kickUnregistered = _configurationService.GetValueOrDefault(nameof(ServicesConfiguration.KickNonRegisteredUsers), false);
|
|
if (roleId == null) return;
|
|
|
|
var registrationRole = guild.Roles.FirstOrDefault(f => f.Id == roleId.Value);
|
|
var registeredUsers = new HashSet<ulong>(await dbContext.LodeStoneAuth.AsNoTracking().Select(c => c.DiscordId).ToListAsync().ConfigureAwait(false));
|
|
|
|
var executionStartTime = DateTimeOffset.UtcNow;
|
|
|
|
int processedUsers = 0;
|
|
int addedRoles = 0;
|
|
int kickedUsers = 0;
|
|
int totalRoles = 0;
|
|
int toRemoveUsers = 0;
|
|
int freshUsers = 0;
|
|
|
|
await _botServices.LogToChannel($"Starting to process registered users: Adding Role {registrationRole.Name}. Kick Stale Unregistered: {kickUnregistered}.").ConfigureAwait(false);
|
|
|
|
await foreach (var userList in guild.GetUsersAsync(new RequestOptions { CancelToken = token }).ConfigureAwait(false))
|
|
{
|
|
_logger.LogInformation("Processing chunk of {count} users, total processed: {proc}, total roles: {total}, roles added: {added}, users kicked: {kicked}, users plan to kick: {planToKick}, fresh user: {fresh}",
|
|
userList.Count, processedUsers, totalRoles + addedRoles, addedRoles, kickedUsers, toRemoveUsers, freshUsers);
|
|
foreach (var user in userList)
|
|
{
|
|
if (user.IsBot) continue;
|
|
|
|
if (registeredUsers.Contains(user.Id))
|
|
{
|
|
bool roleAdded = await _botServices.AddRegisteredRoleAsync(user, registrationRole).ConfigureAwait(false);
|
|
if (roleAdded) addedRoles++;
|
|
else totalRoles++;
|
|
}
|
|
else
|
|
{
|
|
if ((executionStartTime - user.JoinedAt.Value).TotalDays > 7)
|
|
{
|
|
if (kickUnregistered)
|
|
{
|
|
await _botServices.KickUserAsync(user).ConfigureAwait(false);
|
|
kickedUsers++;
|
|
}
|
|
else
|
|
{
|
|
toRemoveUsers++;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
freshUsers++;
|
|
}
|
|
}
|
|
|
|
token.ThrowIfCancellationRequested();
|
|
processedUsers++;
|
|
}
|
|
}
|
|
|
|
await _botServices.LogToChannel($"Processing registered users finished. Processed {processedUsers} users, added {addedRoles} roles and kicked {kickedUsers} users").ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task RemoveUsersNotInVanityRole(CancellationToken token)
|
|
{
|
|
var guild = (await _discordClient.Rest.GetGuildsAsync().ConfigureAwait(false)).First();
|
|
|
|
while (!token.IsCancellationRequested)
|
|
{
|
|
try
|
|
{
|
|
_logger.LogInformation($"Cleaning up Vanity UIDs");
|
|
await _botServices.LogToChannel("Cleaning up Vanity UIDs").ConfigureAwait(false);
|
|
_logger.LogInformation("Getting rest guild {guildName}", guild.Name);
|
|
var restGuild = await _discordClient.Rest.GetGuildAsync(guild.Id).ConfigureAwait(false);
|
|
|
|
Dictionary<ulong, string> allowedRoleIds = _configurationService.GetValueOrDefault(nameof(ServicesConfiguration.VanityRoles), new Dictionary<ulong, string>());
|
|
_logger.LogInformation($"Allowed role ids: {string.Join(", ", allowedRoleIds)}");
|
|
|
|
if (allowedRoleIds.Any())
|
|
{
|
|
using var db = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
|
|
|
|
var aliasedUsers = await db.LodeStoneAuth.Include("User")
|
|
.Where(c => c.User != null && !string.IsNullOrEmpty(c.User.Alias)).ToListAsync().ConfigureAwait(false);
|
|
var aliasedGroups = await db.Groups.Include(u => u.Owner)
|
|
.Where(c => !string.IsNullOrEmpty(c.Alias)).ToListAsync().ConfigureAwait(false);
|
|
|
|
foreach (var lodestoneAuth in aliasedUsers)
|
|
{
|
|
await CheckVanityForUser(restGuild, allowedRoleIds, db, lodestoneAuth, token).ConfigureAwait(false);
|
|
|
|
await Task.Delay(1000, token).ConfigureAwait(false);
|
|
}
|
|
|
|
foreach (var group in aliasedGroups)
|
|
{
|
|
await CheckVanityForGroup(restGuild, allowedRoleIds, db, group, token).ConfigureAwait(false);
|
|
|
|
await Task.Delay(1000, token).ConfigureAwait(false);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_logger.LogInformation("No roles for command defined, no cleanup performed");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Something failed during checking vanity user uids");
|
|
}
|
|
|
|
_logger.LogInformation("Vanity UID cleanup complete");
|
|
await Task.Delay(TimeSpan.FromHours(12), token).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private async Task CheckVanityForGroup(RestGuild restGuild, Dictionary<ulong, string> allowedRoleIds, LightlessDbContext db, Group group, CancellationToken token)
|
|
{
|
|
var groupPrimaryUser = group.OwnerUID;
|
|
var groupOwner = await db.Auth.Include(u => u.User).SingleOrDefaultAsync(u => u.UserUID == group.OwnerUID).ConfigureAwait(false);
|
|
if (groupOwner != null && !string.IsNullOrEmpty(groupOwner.PrimaryUserUID))
|
|
{
|
|
groupPrimaryUser = groupOwner.PrimaryUserUID;
|
|
}
|
|
|
|
var lodestoneUser = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(f => f.User.UID == groupPrimaryUser).ConfigureAwait(false);
|
|
RestGuildUser discordUser = null;
|
|
if (lodestoneUser != null)
|
|
{
|
|
discordUser = await restGuild.GetUserAsync(lodestoneUser.DiscordId).ConfigureAwait(false);
|
|
}
|
|
|
|
_logger.LogInformation($"Checking Group: {group.GID} [{group.Alias}], owned by {group.OwnerUID} ({groupPrimaryUser}), User in Roles: {string.Join(", ", discordUser?.RoleIds ?? new List<ulong>())}");
|
|
|
|
var hasAllowedRole = lodestoneUser != null && discordUser != null && discordUser.RoleIds.Any(allowedRoleIds.Keys.Contains);
|
|
|
|
if (!hasAllowedRole)
|
|
{
|
|
await _botServices.LogToChannel($"VANITY GID REMOVAL: <@{lodestoneUser?.DiscordId ?? 0}> ({lodestoneUser?.User?.UID}) - GID: {group.GID}, Vanity: {group.Alias}").ConfigureAwait(false);
|
|
|
|
_logger.LogInformation($"User {lodestoneUser?.User?.UID ?? "unknown"} not in allowed roles, deleting group alias for {group.GID}");
|
|
group.Alias = null;
|
|
db.Update(group);
|
|
|
|
if (lodestoneUser?.User != null)
|
|
{
|
|
lodestoneUser.User.HasVanity = false;
|
|
db.Update(lodestoneUser.User);
|
|
|
|
var secondaryUsers = await db.Auth.Include(u => u.User)
|
|
.Where(u => u.PrimaryUserUID == lodestoneUser.User.UID).ToListAsync().ConfigureAwait(false);
|
|
|
|
foreach (var secondaryUser in secondaryUsers)
|
|
{
|
|
secondaryUser.User.HasVanity = false;
|
|
db.Update(secondaryUser.User);
|
|
}
|
|
}
|
|
|
|
await db.SaveChangesAsync(token).ConfigureAwait(false);
|
|
}
|
|
else if (lodestoneUser?.User != null && !lodestoneUser.User.HasVanity)
|
|
{
|
|
lodestoneUser.User.HasVanity = true;
|
|
db.Update(lodestoneUser.User);
|
|
|
|
var secondaryUsers = await db.Auth.Include(u => u.User)
|
|
.Where(u => u.PrimaryUserUID == lodestoneUser.User.UID).ToListAsync().ConfigureAwait(false);
|
|
|
|
foreach (var secondaryUser in secondaryUsers)
|
|
{
|
|
if (!secondaryUser.User.HasVanity)
|
|
{
|
|
secondaryUser.User.HasVanity = true;
|
|
db.Update(secondaryUser.User);
|
|
}
|
|
}
|
|
|
|
await db.SaveChangesAsync(token).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private async Task CheckVanityForUser(RestGuild restGuild, Dictionary<ulong, string> allowedRoleIds, LightlessDbContext db, LodeStoneAuth lodestoneAuth, CancellationToken token)
|
|
{
|
|
var discordUser = await restGuild.GetUserAsync(lodestoneAuth.DiscordId).ConfigureAwait(false);
|
|
_logger.LogInformation($"Checking User: {lodestoneAuth.DiscordId}, {lodestoneAuth.User.UID} ({lodestoneAuth.User.Alias}), User in Roles: {string.Join(", ", discordUser?.RoleIds ?? new List<ulong>())}");
|
|
|
|
var hasAllowedRole = discordUser != null && discordUser.RoleIds.Any(u => allowedRoleIds.Keys.Contains(u));
|
|
|
|
if (!hasAllowedRole)
|
|
{
|
|
_logger.LogInformation($"User {lodestoneAuth.User.UID} not in allowed roles, deleting alias");
|
|
await _botServices.LogToChannel($"VANITY UID REMOVAL: <@{lodestoneAuth.DiscordId}> - UID: {lodestoneAuth.User.UID}, Vanity: {lodestoneAuth.User.Alias}").ConfigureAwait(false);
|
|
lodestoneAuth.User.Alias = null;
|
|
lodestoneAuth.User.HasVanity = false;
|
|
var secondaryUsers = await db.Auth.Include(u => u.User).Where(u => u.PrimaryUserUID == lodestoneAuth.User.UID).ToListAsync().ConfigureAwait(false);
|
|
foreach (var secondaryUser in secondaryUsers)
|
|
{
|
|
_logger.LogInformation($"Secondary User {secondaryUser.User.UID} not in allowed roles, deleting alias");
|
|
|
|
secondaryUser.User.Alias = null;
|
|
secondaryUser.User.HasVanity = false;
|
|
db.Update(secondaryUser.User);
|
|
}
|
|
db.Update(lodestoneAuth.User);
|
|
await db.SaveChangesAsync(token).ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
var secondaryUsers = await db.Auth.Include(u => u.User)
|
|
.Where(u => u.PrimaryUserUID == lodestoneAuth.User.UID).ToListAsync().ConfigureAwait(false);
|
|
|
|
var hasChanges = false;
|
|
|
|
if (!lodestoneAuth.User.HasVanity)
|
|
{
|
|
lodestoneAuth.User.HasVanity = true;
|
|
db.Update(lodestoneAuth.User);
|
|
hasChanges = true;
|
|
}
|
|
|
|
foreach (var secondaryUser in secondaryUsers)
|
|
{
|
|
if (!secondaryUser.User.HasVanity)
|
|
{
|
|
secondaryUser.User.HasVanity = true;
|
|
db.Update(secondaryUser.User);
|
|
hasChanges = true;
|
|
}
|
|
}
|
|
|
|
if (hasChanges)
|
|
{
|
|
await db.SaveChangesAsync(token).ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed record ChatReportSnapshotItem(
|
|
string MessageId,
|
|
DateTime SentAtUtc,
|
|
string SenderUserUid,
|
|
string? SenderAlias,
|
|
bool SenderIsLightfinder,
|
|
string? SenderHashedCid,
|
|
string Message);
|
|
|
|
private async Task UpdateStatusAsync(CancellationToken cancellationToken)
|
|
{
|
|
while (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
var endPoint = _connectionMultiplexer.GetEndPoints().First();
|
|
var keys = _connectionMultiplexer.GetServer(endPoint).KeysAsync(pattern: "UID:*");
|
|
var onlineUsers = await keys.CountAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
_logger.LogInformation("Users online: " + onlineUsers);
|
|
await _discordClient.SetActivityAsync(new Game("Lightless for " + onlineUsers + " Users")).ConfigureAwait(false);
|
|
await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|