mirror of
https://github.com/RPCS3/discord-bot.git
synced 2026-01-31 01:25:22 +01:00
230 lines
10 KiB
C#
230 lines
10 KiB
C#
using System.Diagnostics;
|
||
using System.IO;
|
||
using System.Net.Http;
|
||
using CompatApiClient.Compression;
|
||
using CompatBot.Database;
|
||
using ConcurrentCollections;
|
||
using Microsoft.EntityFrameworkCore;
|
||
|
||
namespace CompatBot.Commands;
|
||
|
||
[Command("fortune")]
|
||
internal static class Fortune
|
||
{
|
||
private static readonly SemaphoreSlim ImportCheck = new(1, 1);
|
||
|
||
[Command("open")]
|
||
[Description("Get a personal fortune cookie message once a day")]
|
||
public static async ValueTask ShowFortune(SlashCommandContext ctx)
|
||
{
|
||
var ephemeral = !ctx.Channel.IsSpamChannel() && !ctx.Channel.IsOfftopicChannel();
|
||
if (await GetFortuneAsync(ctx.User).ConfigureAwait(false) is {Length: >0} fortune)
|
||
await ctx.RespondAsync(fortune, ephemeral: ephemeral).ConfigureAwait(false);
|
||
else
|
||
await ctx.RespondAsync($"{Config.Reactions.Failure} There are no fortunes to tell", ephemeral: true).ConfigureAwait(false);
|
||
}
|
||
|
||
[Command("import"), RequiresBotModRole]
|
||
[Description("Import new fortunes from a standard UNIX fortune file")]
|
||
public static async ValueTask Import(
|
||
SlashCommandContext ctx,
|
||
[Description("Link to a plain text file"), MinMaxLength(12)] string? url = null,
|
||
[Description("Text file in UNIX fortunes format")] DiscordAttachment? attachment = null)
|
||
{
|
||
if (!await ImportCheck.WaitAsync(0).ConfigureAwait(false))
|
||
{
|
||
await ctx.RespondAsync($"{Config.Reactions.Failure} There is another import in progress already").ConfigureAwait(false);
|
||
return;
|
||
}
|
||
|
||
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(15*60-5));
|
||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, Config.Cts.Token);
|
||
try
|
||
{
|
||
url ??= attachment?.Url;
|
||
if (string.IsNullOrEmpty(url))
|
||
{
|
||
await ctx.RespondAsync($"{Config.Reactions.Failure} At least one source must be provided").ConfigureAwait(false);
|
||
return;
|
||
}
|
||
|
||
await ctx.RespondAsync("Importing…", ephemeral: true).ConfigureAwait(false);
|
||
var stopwatch = Stopwatch.StartNew();
|
||
using var httpClient = HttpClientFactory.Create(new CompressionMessageHandler());
|
||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||
var response = await httpClient.SendAsync(request, cts.Token).ConfigureAwait(false);
|
||
await using var stream = await response.Content.ReadAsStreamAsync(cts.Token).ConfigureAwait(false);
|
||
using var reader = new StreamReader(stream);
|
||
var buf = new StringBuilder();
|
||
string? line;
|
||
int count = 0, skipped = 0;
|
||
ConcurrentHashSet<string> allFortunes;
|
||
await using (var db = await ThumbnailDb.OpenReadAsync().ConfigureAwait(false))
|
||
{
|
||
allFortunes = new(
|
||
await db.Fortune.AsNoTracking().Select(f => f.Content).ToListAsync(cancellationToken: cts.Token).ConfigureAwait(false),
|
||
StringComparer.OrdinalIgnoreCase
|
||
);
|
||
}
|
||
await using var wdb = await ThumbnailDb.OpenWriteAsync().ConfigureAwait(false);
|
||
while (
|
||
!cts.IsCancellationRequested
|
||
&& ((line = await reader.ReadLineAsync(cts.Token).ConfigureAwait(false)) != null
|
||
|| buf.Length > 0)
|
||
)
|
||
{
|
||
if (line is "%" or null)
|
||
{
|
||
var newFortune = buf.ToString().Replace("\r\n", "\n").Trim();
|
||
if (newFortune.Length > Config.MaxFortuneLength)
|
||
{
|
||
buf.Clear();
|
||
skipped++;
|
||
continue;
|
||
}
|
||
|
||
if (allFortunes.Contains(newFortune))
|
||
{
|
||
buf.Clear();
|
||
skipped++;
|
||
continue;
|
||
}
|
||
|
||
var duplicate = allFortunes
|
||
.AsParallel()
|
||
.WithCancellation(cts.Token)
|
||
.WithDegreeOfParallelism(Math.Max(1, Environment.ProcessorCount - 2))
|
||
.Any(f => f.GetFuzzyCoefficientCached(newFortune) >= 0.95);
|
||
if (duplicate)
|
||
{
|
||
buf.Clear();
|
||
skipped++;
|
||
continue;
|
||
}
|
||
|
||
await wdb.Fortune.AddAsync(new() {Content = newFortune}, cts.Token).ConfigureAwait(false);
|
||
allFortunes.Add(newFortune);
|
||
buf.Clear();
|
||
count++;
|
||
}
|
||
else
|
||
buf.AppendLine(line);
|
||
if (line is null)
|
||
break;
|
||
|
||
if (stopwatch.ElapsedMilliseconds > 10_000)
|
||
{
|
||
var progressMsg = $"Imported {count} fortune{(count == 1 ? "" : "s")}";
|
||
if (skipped > 0)
|
||
progressMsg += $", skipped {skipped}";
|
||
if (response.Content.Headers.ContentLength is long len and > 0)
|
||
progressMsg += $" ({stream.Position * 100.0 / len:0.##}%)";
|
||
await ctx.EditResponseAsync(progressMsg).ConfigureAwait(false);
|
||
stopwatch.Restart();
|
||
}
|
||
}
|
||
await wdb.SaveChangesAsync(cts.Token).ConfigureAwait(false);
|
||
var result = $"{Config.Reactions.Success} Imported {count} fortune{(count == 1 ? "" : "s")}";
|
||
if (skipped > 0)
|
||
result += $", skipped {skipped}";
|
||
await ctx.EditResponseAsync(result).ConfigureAwait(false);
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
await ctx.EditResponseAsync($"{Config.Reactions.Failure} Failed to import data: " + e.Message).ConfigureAwait(false);
|
||
return;
|
||
}
|
||
finally
|
||
{
|
||
ImportCheck.Release();
|
||
}
|
||
if (cts.IsCancellationRequested)
|
||
await ctx.EditResponseAsync($"{Config.Reactions.Failure} Reached time limit for discord interaction").ConfigureAwait(false);
|
||
}
|
||
|
||
[Command("export"), RequiresBotModRole]
|
||
[Description("Export fortune database into UNIX fortune format file")]
|
||
public static async ValueTask Export(SlashCommandContext ctx)
|
||
{
|
||
var ephemeral = !ctx.Channel.IsSpamChannel();
|
||
try
|
||
{
|
||
var count = 0;
|
||
await using var outputStream = Config.MemoryStreamManager.GetStream();
|
||
await using var writer = new StreamWriter(outputStream);
|
||
await using var db = await ThumbnailDb.OpenReadAsync().ConfigureAwait(false);
|
||
foreach (var fortune in db.Fortune.AsNoTracking())
|
||
{
|
||
if (Config.Cts.Token.IsCancellationRequested)
|
||
break;
|
||
|
||
await writer.WriteAsync(fortune.Content).ConfigureAwait(false);
|
||
await writer.WriteAsync("\n%\n").ConfigureAwait(false);
|
||
count++;
|
||
}
|
||
await writer.FlushAsync().ConfigureAwait(false);
|
||
outputStream.Seek(0, SeekOrigin.Begin);
|
||
var builder = new DiscordInteractionResponseBuilder()
|
||
.AsEphemeral(ephemeral)
|
||
.WithContent($"Exported {count} fortune{(count == 1 ? "": "s")}")
|
||
.AddFile("fortunes.txt", outputStream);
|
||
await ctx.RespondAsync(builder).ConfigureAwait(false);
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
await ctx.RespondAsync($"{Config.Reactions.Failure} Failed to export data: " + e.Message, ephemeral: ephemeral).ConfigureAwait(false);
|
||
}
|
||
}
|
||
|
||
[Command("clear"), RequiresBotModRole]
|
||
[Description("Clear fortune database")]
|
||
public static async ValueTask Clear(SlashCommandContext ctx, [Description("Must be `with my blessing, I swear I exported the backup`")] string confirmation)
|
||
{
|
||
if (confirmation is not "with my blessing, I swear I exported the backup")
|
||
{
|
||
await ctx.RespondAsync($"{Config.Reactions.Failure} Incorrect confirmation", ephemeral: true).ConfigureAwait(false);
|
||
return;
|
||
}
|
||
|
||
await ctx.DeferResponseAsync(true).ConfigureAwait(false);
|
||
await using var wdb = await ThumbnailDb.OpenWriteAsync().ConfigureAwait(false);
|
||
wdb.Fortune.RemoveRange(wdb.Fortune);
|
||
var count = await wdb.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false);
|
||
await ctx.RespondAsync($"{Config.Reactions.Success} Removed {count} fortune{(count == 1 ? "" : "s")}", ephemeral: true).ConfigureAwait(false);
|
||
}
|
||
|
||
public static async ValueTask<string?> GetFortuneAsync(DiscordUser user)
|
||
{
|
||
var prefix = DateTime.UtcNow.ToString("yyyyMMdd") + user.Id.ToString("x16");
|
||
var rng = new Random(prefix.GetStableHash());
|
||
await using var db = await ThumbnailDb.OpenReadAsync().ConfigureAwait(false);
|
||
Database.Fortune? fortune;
|
||
do
|
||
{
|
||
var totalFortunes = await db.Fortune.CountAsync().ConfigureAwait(false);
|
||
if (totalFortunes is 0)
|
||
return null;
|
||
|
||
var selectedId = rng.Next(totalFortunes);
|
||
fortune = await db.Fortune.AsNoTracking().Skip(selectedId).FirstOrDefaultAsync().ConfigureAwait(false);
|
||
} while (fortune is null);
|
||
|
||
var tmp = new StringBuilder();
|
||
var quote = true;
|
||
foreach (var l in fortune.Content.FixTypography().Split('\n'))
|
||
{
|
||
var fixedLine = l.Replace("\t", " ");
|
||
//quote &= !fixedLine.StartsWith(" ");
|
||
var trimmed = fixedLine.TrimStart(' ');
|
||
quote &= trimmed is { Length: 0 } || trimmed[0] is not '-' and not '–' and not '—';
|
||
if (quote)
|
||
tmp.Append("> ");
|
||
tmp.Append(l).Append('\n');
|
||
}
|
||
return $"""
|
||
{user.Mention}, your fortune for today:
|
||
{tmp.ToString().TrimEnd().FixSpaces()}
|
||
""";
|
||
}
|
||
}
|