diff --git a/Data/MemberData.cs b/Data/MemberData.cs index e2bee2b..6cbf787 100644 --- a/Data/MemberData.cs +++ b/Data/MemberData.cs @@ -1,3 +1,5 @@ +using Remora.Rest.Core; + namespace Boyfriend.Data; public class MemberData { @@ -8,4 +10,5 @@ public class MemberData { public ulong Id { get; } public DateTimeOffset? BannedUntil { get; set; } + public List<Snowflake> Roles { get; set; } = new(); } diff --git a/EventResponders.cs b/EventResponders.cs index 2efe194..0f095bb 100644 --- a/EventResponders.cs +++ b/EventResponders.cs @@ -288,3 +288,20 @@ public class GuildScheduledEventDeleteResponder : IResponder<IGuildScheduledEven guildData.Configuration.EventNotificationChannel.ToDiscordSnowflake(), embeds: new[] { built }, ct: ct); } } + +/// <summary> +/// Handles updating <see cref="MemberData.Roles" /> when a guild member is updated. +/// </summary> +public class GuildMemberUpdateResponder : IResponder<IGuildMemberUpdate> { + private readonly GuildDataService _dataService; + + public GuildMemberUpdateResponder(GuildDataService dataService) { + _dataService = dataService; + } + + public async Task<Result> RespondAsync(IGuildMemberUpdate gatewayEvent, CancellationToken ct = default) { + var memberData = await _dataService.GetMemberData(gatewayEvent.GuildID, gatewayEvent.User.ID, ct); + memberData.Roles = gatewayEvent.Roles.ToList(); + return Result.FromSuccess(); + } +} diff --git a/InteractionResponders.cs b/InteractionResponders.cs index 6d2b729..231df31 100644 --- a/InteractionResponders.cs +++ b/InteractionResponders.cs @@ -23,7 +23,7 @@ public class InteractionResponders : InteractionGroup { /// A button that will output an ephemeral embed containing the information about a scheduled event. /// </summary> /// <param name="state">The ID of the guild and scheduled event, encoded as "guildId:eventId".</param> - /// <returns>A feedback sending result which may or may not have succeeded.</returns> + /// <returns>An ephemeral feedback sending result which may or may not have succeeded.</returns> [Button("scheduled-event-details")] public async Task<Result> OnStatefulButtonClicked(string? state = null) { if (state is null) return Result.FromError(new ArgumentNullError(nameof(state))); diff --git a/Services/Data/GuildDataService.cs b/Services/Data/GuildDataService.cs index 4c4aa88..4552597 100644 --- a/Services/Data/GuildDataService.cs +++ b/Services/Data/GuildDataService.cs @@ -1,6 +1,8 @@ using System.Text.Json; using Boyfriend.Data; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Remora.Discord.API.Abstractions.Rest; using Remora.Rest.Core; namespace Boyfriend.Services.Data; @@ -10,9 +12,14 @@ namespace Boyfriend.Services.Data; /// </summary> public class GuildDataService : IHostedService { private readonly Dictionary<Snowflake, GuildData> _datas = new(); + private readonly IDiscordRestGuildAPI _guildApi; + private readonly ILogger<GuildDataService> _logger; // https://github.com/dotnet/aspnetcore/issues/39139 - public GuildDataService(IHostApplicationLifetime lifetime) { + public GuildDataService( + IHostApplicationLifetime lifetime, IDiscordRestGuildAPI guildApi, ILogger<GuildDataService> logger) { + _guildApi = guildApi; + _logger = logger; lifetime.ApplicationStopping.Register(ApplicationStopping); } @@ -75,6 +82,11 @@ public class GuildDataService : IHostedService { await using var dataStream = File.OpenRead(dataPath); var data = await JsonSerializer.DeserializeAsync<MemberData>(dataStream, cancellationToken: ct); if (data is null) continue; + var memberResult = await _guildApi.GetGuildMemberAsync(guildId, data.Id.ToDiscordSnowflake(), ct); + if (memberResult.IsSuccess) + data.Roles = memberResult.Entity.Roles.ToList(); + else + _logger.LogWarning("Error in member retrieval.\n{ErrorMessage}", memberResult.Error.Message); memberData.Add(data.Id, data); } @@ -91,9 +103,9 @@ public class GuildDataService : IHostedService { return (await GetData(guildId, ct)).Configuration; } - /*public async Task<MemberData> GetMemberData(Snowflake guildId, Snowflake userId, CancellationToken ct = default) { + public async Task<MemberData> GetMemberData(Snowflake guildId, Snowflake userId, CancellationToken ct = default) { return (await GetData(guildId, ct)).GetMemberData(userId); - }*/ + } public IEnumerable<Snowflake> GetGuildIds() { return _datas.Keys; diff --git a/Services/GuildUpdateService.cs b/Services/GuildUpdateService.cs index b977591..dcc4d1b 100644 --- a/Services/GuildUpdateService.cs +++ b/Services/GuildUpdateService.cs @@ -63,7 +63,9 @@ public class GuildUpdateService : BackgroundService { /// This method does the following: /// <list type="bullet"> /// <item>Automatically unbans users once their ban period has expired.</item> + /// <item>Automatically grants users the guild's <see cref="GuildConfiguration.DefaultRole"/> if one is set.</item> /// <item>Sends reminders about an upcoming scheduled event.</item> + /// <item>Automatically starts scheduled events if <see cref="GuildConfiguration.AutoStartEvents"/> is enabled.</item> /// <item>Sends scheduled event start notifications.</item> /// <item>Sends scheduled event completion notifications.</item> /// </list> @@ -73,7 +75,7 @@ public class GuildUpdateService : BackgroundService { /// Downtime would affect the reliability of notifications and automatic unbans if this logic were to be in a /// <see cref="IResponder{TGatewayEvent}" />. /// </item> - /// <item>The Discord API doesn't provide necessary about scheduled event updates.</item> + /// <item>The Discord API doesn't provide necessary information about scheduled event updates.</item> /// </list> /// </remarks> /// <param name="guildId">The ID of the guild to update.</param> @@ -81,17 +83,29 @@ public class GuildUpdateService : BackgroundService { private async Task TickGuildAsync(Snowflake guildId, CancellationToken ct = default) { var data = await _dataService.GetData(guildId, ct); Messages.Culture = data.Culture; + var defaultRoleSnowflake = data.Configuration.DefaultRole.ToDiscordSnowflake(); + + foreach (var memberData in data.MemberData.Values) { + var userIdSnowflake = memberData.Id.ToDiscordSnowflake(); + if (!memberData.Roles.Contains(defaultRoleSnowflake)) { + var defaultRoleResult = await _guildApi.AddGuildMemberRoleAsync( + guildId, userIdSnowflake, defaultRoleSnowflake, ct: ct); + if (!defaultRoleResult.IsSuccess) + _logger.LogWarning( + "Error in automatic default role add request.\n{ErrorMessage}", + defaultRoleResult.Error.Message); + } - // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator - foreach (var memberData in data.MemberData.Values) if (DateTimeOffset.UtcNow > memberData.BannedUntil) { var unbanResult = await _guildApi.RemoveGuildBanAsync( - guildId, memberData.Id.ToDiscordSnowflake(), Messages.PunishmentExpired.EncodeHeader(), ct); + guildId, userIdSnowflake, Messages.PunishmentExpired.EncodeHeader(), ct); if (unbanResult.IsSuccess) memberData.BannedUntil = null; else - _logger.LogWarning("Error in member data update.\n{ErrorMessage}", unbanResult.Error.Message); + _logger.LogWarning( + "Error in automatic user unban request.\n{ErrorMessage}", unbanResult.Error.Message); } + } var eventsResult = await _eventApi.ListScheduledEventsForGuildAsync(guildId, ct: ct); if (!eventsResult.IsDefined(out var events)) return; @@ -104,9 +118,19 @@ public class GuildUpdateService : BackgroundService { } else { var storedEvent = data.ScheduledEvents[scheduledEvent.ID.Value]; if (storedEvent.Status == scheduledEvent.Status) { - if (DateTimeOffset.UtcNow - >= scheduledEvent.ScheduledStartTime - data.Configuration.EventEarlyNotificationOffset - && !storedEvent.EarlyNotificationSent) { + if (DateTimeOffset.UtcNow >= scheduledEvent.ScheduledStartTime) { + if (scheduledEvent.Status is not GuildScheduledEventStatus.Active) { + var startResult = await _eventApi.ModifyGuildScheduledEventAsync( + guildId, scheduledEvent.ID, + status: GuildScheduledEventStatus.Active, ct: ct); + if (!startResult.IsSuccess) + _logger.LogWarning( + "Error in automatic scheduled event start request.\n{ErrorMessage}", + startResult.Error.Message); + } + } else if (DateTimeOffset.UtcNow + >= scheduledEvent.ScheduledStartTime - data.Configuration.EventEarlyNotificationOffset + && !storedEvent.EarlyNotificationSent) { var earlyResult = await SendScheduledEventUpdatedMessage(scheduledEvent, data, true, ct); if (earlyResult.IsSuccess) storedEvent.EarlyNotificationSent = true;