diff --git a/Boyfriend.cs b/Boyfriend.cs index 4be3f18..00bd936 100644 --- a/Boyfriend.cs +++ b/Boyfriend.cs @@ -74,6 +74,7 @@ public class Boyfriend { .AddInteractionGroup() .AddSingleton() .AddSingleton() + .AddHostedService() .AddCommandTree() .WithCommandGroup(); var responderTypes = typeof(Boyfriend).Assembly diff --git a/Commands/BanCommandGroup.cs b/Commands/BanCommandGroup.cs index 92ed248..0ed30f0 100644 --- a/Commands/BanCommandGroup.cs +++ b/Commands/BanCommandGroup.cs @@ -1,5 +1,4 @@ using System.ComponentModel; -using System.Net; using Boyfriend.Services; using Boyfriend.Services.Data; using Remora.Commands.Attributes; @@ -48,8 +47,9 @@ public class BanCommandGroup : CommandGroup { /// A slash command that bans a Discord user with the specified reason. /// /// The user to ban. + /// The duration for this ban. The user will be automatically unbanned after this duration. /// - /// The reason for this ban. Must be encoded with when passed to + /// The reason for this ban. Must be encoded with when passed to /// . /// /// @@ -62,7 +62,8 @@ public class BanCommandGroup : CommandGroup { [RequireDiscordPermission(DiscordPermission.BanMembers)] [RequireBotDiscordPermissions(DiscordPermission.BanMembers)] [Description("банит пидора")] - public async Task BanUserAsync([Description("Юзер, кого банить")] IUser target, string reason) { + public async Task BanUserAsync( + [Description("Юзер, кого банить")] IUser target, string reason, TimeSpan? duration = null) { // Data checks if (!_context.TryGetGuildID(out var guildId)) return Result.FromError(new ArgumentNullError(nameof(guildId))); @@ -76,8 +77,9 @@ public class BanCommandGroup : CommandGroup { if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult); - var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken); - Messages.Culture = cfg.Culture; + var data = await _dataService.GetData(guildId.Value, CancellationToken); + var cfg = data.Configuration; + Messages.Culture = data.Culture; var existingBanResult = await _guildApi.GetGuildBanAsync(guildId.Value, target.ID, CancellationToken); if (existingBanResult.IsDefined()) { @@ -105,10 +107,12 @@ public class BanCommandGroup : CommandGroup { return Result.FromError(userResult); var banResult = await _guildApi.CreateGuildBanAsync( - guildId.Value, target.ID, reason: $"({user.GetTag()}) {WebUtility.UrlEncode(reason)}", + guildId.Value, target.ID, reason: $"({user.GetTag()}) {reason.EncodeHeader()}", ct: CancellationToken); if (!banResult.IsSuccess) return Result.FromError(banResult.Error); + data.GetMemberData(target.ID).BannedUntil + = duration is not null ? DateTimeOffset.UtcNow.Add(duration.Value) : DateTimeOffset.MaxValue; responseEmbed = new EmbedBuilder().WithSmallTitle( string.Format(Messages.UserBanned, target.GetTag()), target) @@ -151,7 +155,7 @@ public class BanCommandGroup : CommandGroup { /// /// The user to unban. /// - /// The reason for this unban. Must be encoded with when passed to + /// The reason for this unban. Must be encoded with when passed to /// . /// /// @@ -179,7 +183,7 @@ public class BanCommandGroup : CommandGroup { return Result.FromError(currentUserResult); var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken); - Messages.Culture = cfg.Culture; + Messages.Culture = cfg.GetCulture(); var existingBanResult = await _guildApi.GetGuildBanAsync(guildId.Value, target.ID, CancellationToken); if (!existingBanResult.IsDefined()) { @@ -198,7 +202,7 @@ public class BanCommandGroup : CommandGroup { return Result.FromError(userResult); var unbanResult = await _guildApi.RemoveGuildBanAsync( - guildId.Value, target.ID, reason: $"({user.GetTag()}) {WebUtility.UrlEncode(reason)}", + guildId.Value, target.ID, $"({user.GetTag()}) {reason.EncodeHeader()}", ct: CancellationToken); if (!unbanResult.IsSuccess) return Result.FromError(unbanResult.Error); @@ -232,7 +236,6 @@ public class BanCommandGroup : CommandGroup { ct: CancellationToken); } - if (!responseEmbed.IsDefined(out var built)) return Result.FromError(responseEmbed); diff --git a/Data/GuildConfiguration.cs b/Data/GuildConfiguration.cs index 61111df..d5d490c 100644 --- a/Data/GuildConfiguration.cs +++ b/Data/GuildConfiguration.cs @@ -83,5 +83,7 @@ public class GuildConfiguration { /// public TimeSpan EventEarlyNotificationOffset { get; set; } = TimeSpan.Zero; - public CultureInfo Culture => CultureInfoCache[Language]; + public CultureInfo GetCulture() { + return CultureInfoCache[Language]; + } } diff --git a/Data/GuildData.cs b/Data/GuildData.cs index 37aa958..992adc0 100644 --- a/Data/GuildData.cs +++ b/Data/GuildData.cs @@ -1,4 +1,5 @@ using System.Globalization; +using Remora.Rest.Core; namespace Boyfriend.Data; @@ -10,17 +11,31 @@ public class GuildData { public readonly GuildConfiguration Configuration; public readonly string ConfigurationPath; + public readonly Dictionary MemberData; + public readonly string MemberDataPath; + public readonly Dictionary ScheduledEvents; public readonly string ScheduledEventsPath; public GuildData( GuildConfiguration configuration, string configurationPath, - Dictionary scheduledEvents, string scheduledEventsPath) { + Dictionary scheduledEvents, string scheduledEventsPath, + Dictionary memberData, string memberDataPath) { Configuration = configuration; ConfigurationPath = configurationPath; ScheduledEvents = scheduledEvents; ScheduledEventsPath = scheduledEventsPath; + MemberData = memberData; + MemberDataPath = memberDataPath; } - public CultureInfo Culture => Configuration.Culture; + public CultureInfo Culture => Configuration.GetCulture(); + + public MemberData GetMemberData(Snowflake userId) { + if (MemberData.TryGetValue(userId.Value, out var existing)) return existing; + + var newData = new MemberData(userId.Value, null); + MemberData.Add(userId.Value, newData); + return newData; + } } diff --git a/Data/MemberData.cs b/Data/MemberData.cs new file mode 100644 index 0000000..e2bee2b --- /dev/null +++ b/Data/MemberData.cs @@ -0,0 +1,11 @@ +namespace Boyfriend.Data; + +public class MemberData { + public MemberData(ulong id, DateTimeOffset? bannedUntil) { + Id = id; + BannedUntil = bannedUntil; + } + + public ulong Id { get; } + public DateTimeOffset? BannedUntil { get; set; } +} diff --git a/Data/ScheduledEventData.cs b/Data/ScheduledEventData.cs index 41cb449..5bb1c10 100644 --- a/Data/ScheduledEventData.cs +++ b/Data/ScheduledEventData.cs @@ -7,10 +7,10 @@ namespace Boyfriend.Data; /// /// This information is stored on disk as a JSON file. public class ScheduledEventData { - public DateTimeOffset? ActualStartTime; - public GuildScheduledEventStatus Status; - public ScheduledEventData(GuildScheduledEventStatus status) { Status = status; } + + public DateTimeOffset? ActualStartTime { get; set; } + public GuildScheduledEventStatus Status { get; set; } } diff --git a/EventResponders.cs b/EventResponders.cs index 689843a..22df360 100644 --- a/EventResponders.cs +++ b/EventResponders.cs @@ -32,12 +32,12 @@ public class GuildCreateResponder : IResponder { private readonly IDiscordRestUserAPI _userApi; public GuildCreateResponder( - IDiscordRestChannelAPI channelApi, GuildDataService dataService, IDiscordRestUserAPI userApi, - ILogger logger) { + IDiscordRestChannelAPI channelApi, GuildDataService dataService, ILogger logger, + IDiscordRestUserAPI userApi) { _channelApi = channelApi; _dataService = dataService; - _userApi = userApi; _logger = logger; + _userApi = userApi; } public async Task RespondAsync(IGuildCreate gatewayEvent, CancellationToken ct = default) { @@ -55,7 +55,7 @@ public class GuildCreateResponder : IResponder { var currentUserResult = await _userApi.GetCurrentUserAsync(ct); if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult); - Messages.Culture = guildConfig.Culture; + Messages.Culture = guildConfig.GetCulture(); var i = Random.Shared.Next(1, 4); var embed = new EmbedBuilder() @@ -116,7 +116,7 @@ public class MessageDeletedResponder : IResponder { if (!userResult.IsDefined(out user)) return Result.FromError(userResult); } - Messages.Culture = guildConfiguration.Culture; + Messages.Culture = guildConfiguration.GetCulture(); var embed = new EmbedBuilder() .WithSmallTitle( @@ -194,7 +194,7 @@ public class MessageEditedResponder : IResponder { var diff = new SideBySideDiffBuilder(Differ.Instance).BuildDiffModel(message.Content, newContent, true, true); - Messages.Culture = guildConfiguration.Culture; + Messages.Culture = guildConfiguration.GetCulture(); var embed = new EmbedBuilder() .WithSmallTitle(string.Format(Messages.CachedMessageEdited, message.Author.GetTag()), message.Author) @@ -234,7 +234,7 @@ public class GuildMemberAddResponder : IResponder { if (guildConfiguration.WelcomeMessage is "off" or "disable" or "disabled") return Result.FromSuccess(); - Messages.Culture = guildConfiguration.Culture; + Messages.Culture = guildConfiguration.GetCulture(); var welcomeMessage = guildConfiguration.WelcomeMessage is "default" or "reset" ? Messages.DefaultWelcomeMessage : guildConfiguration.WelcomeMessage; diff --git a/Extensions.cs b/Extensions.cs index 7c7c5d4..b13a52c 100644 --- a/Extensions.cs +++ b/Extensions.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Text; using DiffPlex.DiffBuilder.Model; using Remora.Discord.API; @@ -108,6 +109,10 @@ public static class Extensions { return Messages.ResourceManager.GetString(key, Messages.Culture) ?? key; } + public static string EncodeHeader(this string s) { + return WebUtility.UrlEncode(s).Replace('+', ' '); + } + public static string AsMarkdown(this SideBySideDiffModel model) { var builder = new StringBuilder(); foreach (var line in model.OldText.Lines.Where(piece => !string.IsNullOrWhiteSpace(piece.Text))) diff --git a/Services/Data/GuildDataService.cs b/Services/Data/GuildDataService.cs index 3de57b1..f1dcf82 100644 --- a/Services/Data/GuildDataService.cs +++ b/Services/Data/GuildDataService.cs @@ -11,11 +11,24 @@ namespace Boyfriend.Services.Data; public class GuildDataService : IHostedService { private readonly Dictionary _datas = new(); + // https://github.com/dotnet/aspnetcore/issues/39139 + public GuildDataService(IHostApplicationLifetime lifetime) { + lifetime.ApplicationStopping.Register(ApplicationStopping); + } + public Task StartAsync(CancellationToken ct) { return Task.CompletedTask; } - public async Task StopAsync(CancellationToken ct) { + public Task StopAsync(CancellationToken ct) { + return Task.CompletedTask; + } + + private void ApplicationStopping() { + SaveAsync(CancellationToken.None).GetAwaiter().GetResult(); + } + + private async Task SaveAsync(CancellationToken ct) { var tasks = new List(); foreach (var data in _datas.Values) { await using var configStream = File.OpenWrite(data.ConfigurationPath); @@ -23,6 +36,11 @@ public class GuildDataService : IHostedService { await using var eventsStream = File.OpenWrite(data.ScheduledEventsPath); tasks.Add(JsonSerializer.SerializeAsync(eventsStream, data.ScheduledEvents, cancellationToken: ct)); + + foreach (var memberData in data.MemberData.Values) { + await using var memberDataStream = File.OpenWrite($"{data.MemberDataPath}/{memberData.Id}.json"); + tasks.Add(JsonSerializer.SerializeAsync(memberDataStream, memberData, cancellationToken: ct)); + } } await Task.WhenAll(tasks); @@ -34,11 +52,11 @@ public class GuildDataService : IHostedService { private async Task InitializeData(Snowflake guildId, CancellationToken ct = default) { var idString = $"{guildId}"; - var memberDataDir = $"{guildId}/MemberData"; + var memberDataPath = $"{guildId}/MemberData"; var configurationPath = $"{guildId}/Configuration.json"; var scheduledEventsPath = $"{guildId}/ScheduledEvents.json"; if (!Directory.Exists(idString)) Directory.CreateDirectory(idString); - if (!Directory.Exists(memberDataDir)) Directory.CreateDirectory(memberDataDir); + if (!Directory.Exists(memberDataPath)) Directory.CreateDirectory(memberDataPath); if (!File.Exists(configurationPath)) await File.WriteAllTextAsync(configurationPath, "{}", ct); if (!File.Exists(scheduledEventsPath)) await File.WriteAllTextAsync(scheduledEventsPath, "{}", ct); @@ -52,15 +70,32 @@ public class GuildDataService : IHostedService { = JsonSerializer.DeserializeAsync>( eventsStream, cancellationToken: ct); - var data = new GuildData( + var memberData = new Dictionary(); + foreach (var dataPath in Directory.GetFiles(memberDataPath)) { + await using var dataStream = File.OpenRead(dataPath); + var data = await JsonSerializer.DeserializeAsync(dataStream, cancellationToken: ct); + if (data is null) continue; + + memberData.Add(data.Id, data); + } + + var finalData = new GuildData( await configuration ?? new GuildConfiguration(), configurationPath, - await events ?? new Dictionary(), - scheduledEventsPath); - _datas.Add(guildId, data); - return data; + await events ?? new Dictionary(), scheduledEventsPath, + memberData, memberDataPath); + _datas.Add(guildId, finalData); + return finalData; } public async Task GetConfiguration(Snowflake guildId, CancellationToken ct = default) { return (await GetData(guildId, ct)).Configuration; } + + public async Task GetMemberData(Snowflake guildId, Snowflake userId, CancellationToken ct = default) { + return (await GetData(guildId, ct)).GetMemberData(userId); + } + + public List GetGuildIds() { + return _datas.Keys.ToList(); + } } diff --git a/Services/GuildUpdateService.cs b/Services/GuildUpdateService.cs new file mode 100644 index 0000000..d87bd87 --- /dev/null +++ b/Services/GuildUpdateService.cs @@ -0,0 +1,41 @@ +using Boyfriend.Services.Data; +using Microsoft.Extensions.Hosting; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Rest.Core; + +namespace Boyfriend.Services; + +public class GuildUpdateService : BackgroundService { + private readonly GuildDataService _dataService; + private readonly IDiscordRestGuildAPI _guildApi; + + public GuildUpdateService(GuildDataService dataService, IDiscordRestGuildAPI guildApi) { + _dataService = dataService; + _guildApi = guildApi; + } + + protected override async Task ExecuteAsync(CancellationToken ct) { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + var tasks = new List(); + + while (await timer.WaitForNextTickAsync(ct)) { + foreach (var id in _dataService.GetGuildIds()) + tasks.Add(TickGuildAsync(id, ct)); + + await Task.WhenAll(tasks); + tasks.Clear(); + } + } + + private async Task TickGuildAsync(Snowflake guildId, CancellationToken ct = default) { + var data = await _dataService.GetData(guildId, ct); + Messages.Culture = data.Culture; + + foreach (var memberData in data.MemberData.Values) + if (DateTimeOffset.UtcNow > memberData.BannedUntil) { + _ = _guildApi.RemoveGuildBanAsync( + guildId, memberData.Id.ToDiscordSnowflake(), Messages.PunishmentExpired.EncodeHeader(), ct); + memberData.BannedUntil = null; + } + } +}