1
0
Fork 1
mirror of https://github.com/TeamOctolings/Octobot.git synced 2025-01-31 09:09:00 +03:00
Octobot/Services/GuildUpdateService.cs
Octol1ttle abbb58f801
Switch to Remora.Discord (#41)
result checks go brrr

this also involves switching to using Discord's modern stuff like embeds
and interactions

and using brand-new for me programming concepts (dependency injection,
results)

---------

Signed-off-by: Octol1ttle <l1ttleofficial@outlook.com>
Signed-off-by: mctaylors <95250141+mctaylors@users.noreply.github.com>
Co-authored-by: mctaylors <95250141+mctaylors@users.noreply.github.com>
Co-authored-by: nrdk <neroduck@vk.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-09 16:32:14 +03:00

389 lines
21 KiB
C#

using Boyfriend.Data;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Gateway.Commands;
using Remora.Discord.API.Objects;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Discord.Gateway;
using Remora.Discord.Gateway.Responders;
using Remora.Discord.Interactivity;
using Remora.Rest.Core;
using Remora.Results;
namespace Boyfriend.Services;
/// <summary>
/// Handles executing guild updates (also called "ticks") once per second.
/// </summary>
public class GuildUpdateService : BackgroundService {
private static readonly (string Name, TimeSpan Duration)[] SongList = {
("UNDEAD CORPORATION - The Empress", new TimeSpan(0, 4, 34)),
("UNDEAD CORPORATION - Everything will freeze", new TimeSpan(0, 3, 17)),
("Splatoon 3 - Rockagilly Blues (Yoko & the Gold Bazookas)", new TimeSpan(0, 3, 37)),
("Splatoon 3 - Seep and Destroy", new TimeSpan(0, 2, 42)),
("IA - A Tale of Six Trillion Years and a Night", new TimeSpan(0, 3, 40)),
("Manuel - Gas Gas Gas", new TimeSpan(0, 3, 17)),
("Camellia - Flamewall", new TimeSpan(0, 6, 50))
};
private readonly List<Activity> _activityList = new(1) { new Activity("with Remora.Discord", ActivityType.Game) };
private readonly IDiscordRestChannelAPI _channelApi;
private readonly DiscordGatewayClient _client;
private readonly GuildDataService _dataService;
private readonly IDiscordRestGuildScheduledEventAPI _eventApi;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly ILogger<GuildUpdateService> _logger;
private readonly IDiscordRestUserAPI _userApi;
private readonly UtilityService _utility;
private DateTimeOffset _nextSongAt = DateTimeOffset.MinValue;
private uint _nextSongIndex;
public GuildUpdateService(
IDiscordRestChannelAPI channelApi, DiscordGatewayClient client, GuildDataService dataService,
IDiscordRestGuildScheduledEventAPI eventApi, IDiscordRestGuildAPI guildApi, ILogger<GuildUpdateService> logger,
IDiscordRestUserAPI userApi, UtilityService utility) {
_channelApi = channelApi;
_client = client;
_dataService = dataService;
_eventApi = eventApi;
_guildApi = guildApi;
_logger = logger;
_userApi = userApi;
_utility = utility;
}
/// <summary>
/// Activates a periodic timer with a 1 second interval and adds guild update tasks on each timer tick.
/// Additionally, updates the current presence with songs from <see cref="SongList"/>.
/// </summary>
/// <remarks>If update tasks take longer than 1 second, the next timer tick will be skipped.</remarks>
/// <param name="ct">The cancellation token for this operation.</param>
protected override async Task ExecuteAsync(CancellationToken ct) {
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
var tasks = new List<Task>();
while (await timer.WaitForNextTickAsync(ct)) {
var guildIds = _dataService.GetGuildIds();
if (guildIds.Count > 0 && DateTimeOffset.UtcNow >= _nextSongAt) {
var nextSong = SongList[_nextSongIndex];
_activityList[0] = new Activity(nextSong.Name, ActivityType.Listening);
_client.SubmitCommand(
new UpdatePresence(
UserStatus.Online, false, DateTimeOffset.UtcNow, _activityList));
_nextSongAt = DateTimeOffset.UtcNow.Add(nextSong.Duration);
_nextSongIndex++;
if (_nextSongIndex >= SongList.Length) _nextSongIndex = 0;
}
tasks.AddRange(guildIds.Select(id => TickGuildAsync(id, ct)));
await Task.WhenAll(tasks);
tasks.Clear();
}
}
/// <summary>
/// Runs an update ("tick") for a guild with the provided <paramref name="guildId" />.
/// </summary>
/// <remarks>
/// This method does the following:
/// <list type="bullet">
/// <item>Automatically unbans users once their ban period has expired.</item>
/// <item>Automatically grants members 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>
/// <item>Sends reminders to members.</item>
/// </list>
/// This is done here and not in a <see cref="IResponder{TGatewayEvent}" /> for the following reasons:
/// <list type="bullet">
/// <item>
/// 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 information about scheduled event updates.</item>
/// </list>
/// </remarks>
/// <param name="guildId">The ID of the guild to update.</param>
/// <param name="ct">The cancellation token for this operation.</param>
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 userId = memberData.Id.ToDiscordSnowflake();
if (defaultRoleSnowflake.Value is not 0 && !memberData.Roles.Contains(defaultRoleSnowflake))
_ = _guildApi.AddGuildMemberRoleAsync(
guildId, userId, defaultRoleSnowflake, ct: ct);
if (DateTimeOffset.UtcNow > memberData.BannedUntil) {
var unbanResult = await _guildApi.RemoveGuildBanAsync(
guildId, userId, Messages.PunishmentExpired.EncodeHeader(), ct);
if (unbanResult.IsSuccess)
memberData.BannedUntil = null;
else
_logger.LogWarning(
"Error in automatic user unban request.\n{ErrorMessage}", unbanResult.Error.Message);
}
var userResult = await _userApi.GetUserAsync(userId, ct);
if (!userResult.IsDefined(out var user)) continue;
for (var i = memberData.Reminders.Count - 1; i >= 0; i--) {
var reminder = memberData.Reminders[i];
if (DateTimeOffset.UtcNow < reminder.RemindAt) continue;
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.Reminder, user.GetTag()), user)
.WithDescription(
string.Format(Messages.DescriptionReminder, Markdown.InlineCode(reminder.Text)))
.WithColour(ColorsList.Magenta)
.Build();
if (!embed.IsDefined(out var built)) continue;
var messageResult = await _channelApi.CreateMessageAsync(
reminder.Channel, Mention.User(user), embeds: new[] { built }, ct: ct);
if (!messageResult.IsSuccess)
_logger.LogWarning(
"Error in reminder send.\n{ErrorMessage}", messageResult.Error.Message);
memberData.Reminders.Remove(reminder);
}
}
var eventsResult = await _eventApi.ListScheduledEventsForGuildAsync(guildId, ct: ct);
if (!eventsResult.IsDefined(out var events)) return;
if (data.Configuration.EventNotificationChannel is 0) return;
foreach (var scheduledEvent in events) {
if (!data.ScheduledEvents.ContainsKey(scheduledEvent.ID.Value)) {
data.ScheduledEvents.Add(scheduledEvent.ID.Value, new ScheduledEventData(scheduledEvent.Status));
} else {
var storedEvent = data.ScheduledEvents[scheduledEvent.ID.Value];
if (storedEvent.Status == scheduledEvent.Status) {
if (DateTimeOffset.UtcNow >= scheduledEvent.ScheduledStartTime) {
if (data.Configuration.AutoStartEvents
&& 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 (data.Configuration.EventEarlyNotificationOffset != TimeSpan.Zero
&& !storedEvent.EarlyNotificationSent
&& DateTimeOffset.UtcNow
>= scheduledEvent.ScheduledStartTime - data.Configuration.EventEarlyNotificationOffset) {
var earlyResult = await SendScheduledEventUpdatedMessage(scheduledEvent, data, true, ct);
if (earlyResult.IsSuccess)
storedEvent.EarlyNotificationSent = true;
else
_logger.LogWarning(
"Error in scheduled event early notification sender.\n{ErrorMessage}",
earlyResult.Error.Message);
}
continue;
}
storedEvent.Status = scheduledEvent.Status;
}
var result = scheduledEvent.Status switch {
GuildScheduledEventStatus.Scheduled =>
await SendScheduledEventCreatedMessage(scheduledEvent, data.Configuration, ct),
GuildScheduledEventStatus.Active or GuildScheduledEventStatus.Completed =>
await SendScheduledEventUpdatedMessage(scheduledEvent, data, false, ct),
_ => Result.FromError(new ArgumentOutOfRangeError(nameof(scheduledEvent.Status)))
};
if (!result.IsSuccess)
_logger.LogWarning("Error in guild update.\n{ErrorMessage}", result.Error.Message);
}
}
/// <summary>
/// Handles sending a notification, mentioning the <see cref="GuildConfiguration.EventNotificationRole" /> if one is
/// set,
/// when a scheduled event is created
/// in a guild's <see cref="GuildConfiguration.EventNotificationChannel" /> if one is set.
/// </summary>
/// <param name="scheduledEvent">The scheduled event that has just been created.</param>
/// <param name="config">The configuration of the guild containing the scheduled event.</param>
/// <param name="ct">The cancellation token for this operation.</param>
/// <returns>A notification sending result which may or may not have succeeded.</returns>
private async Task<Result> SendScheduledEventCreatedMessage(
IGuildScheduledEvent scheduledEvent, GuildConfiguration config, CancellationToken ct = default) {
var currentUserResult = await _userApi.GetCurrentUserAsync(ct);
if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult);
if (!scheduledEvent.CreatorID.IsDefined(out var creatorId))
return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.CreatorID)));
var creatorResult = await _userApi.GetUserAsync(creatorId.Value, ct);
if (!creatorResult.IsDefined(out var creator)) return Result.FromError(creatorResult);
string embedDescription;
var eventDescription = scheduledEvent.Description is { HasValue: true, Value: not null }
? scheduledEvent.Description.Value
: string.Empty;
switch (scheduledEvent.EntityType) {
case GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice:
if (!scheduledEvent.ChannelID.AsOptional().IsDefined(out var channelId))
return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.ChannelID)));
embedDescription = $"{eventDescription}\n\n{Markdown.BlockQuote(
string.Format(
Messages.DescriptionLocalEventCreated,
Markdown.Timestamp(scheduledEvent.ScheduledStartTime),
Mention.Channel(channelId)
))}";
break;
case GuildScheduledEventEntityType.External:
if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata))
return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.EntityMetadata)));
if (!scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out var endTime))
return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime)));
if (!metadata.Location.IsDefined(out var location))
return Result.FromError(new ArgumentNullError(nameof(metadata.Location)));
embedDescription = $"{eventDescription}\n\n{Markdown.BlockQuote(
string.Format(
Messages.DescriptionExternalEventCreated,
Markdown.Timestamp(scheduledEvent.ScheduledStartTime),
Markdown.Timestamp(endTime),
Markdown.InlineCode(location)
))}";
break;
default:
return Result.FromError(new ArgumentOutOfRangeError(nameof(scheduledEvent.EntityType)));
}
var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(Messages.EventCreatedTitle, creator.GetTag()), creator)
.WithTitle(scheduledEvent.Name)
.WithDescription(embedDescription)
.WithEventCover(scheduledEvent.ID, scheduledEvent.Image)
.WithUserFooter(currentUser)
.WithCurrentTimestamp()
.WithColour(ColorsList.White)
.Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed);
var roleMention = config.EventNotificationRole is not 0
? Mention.Role(config.EventNotificationRole.ToDiscordSnowflake())
: string.Empty;
var button = new ButtonComponent(
ButtonComponentStyle.Primary,
Messages.EventDetailsButton,
new PartialEmoji(Name: "📋"),
CustomIDHelpers.CreateButtonIDWithState(
"scheduled-event-details", $"{scheduledEvent.GuildID}:{scheduledEvent.ID}")
);
return (Result)await _channelApi.CreateMessageAsync(
config.EventNotificationChannel.ToDiscordSnowflake(), roleMention, embeds: new[] { built },
components: new[] { new ActionRowComponent(new[] { button }) }, ct: ct);
}
/// <summary>
/// Handles sending a notification, mentioning the <see cref="GuildConfiguration.EventStartedReceivers" />s,
/// when a scheduled event is about to start, has started or completed
/// in a guild's <see cref="GuildConfiguration.EventNotificationChannel" /> if one is set.
/// </summary>
/// <param name="scheduledEvent">The scheduled event that is about to start, has started or completed.</param>
/// <param name="data">The data for the guild containing the scheduled event.</param>
/// <param name="early">Controls whether or not a reminder for the scheduled event should be sent instead of the event started/completed notification</param>
/// <param name="ct">The cancellation token for this operation</param>
/// <returns>A reminder/notification sending result which may or may not have succeeded.</returns>
private async Task<Result> SendScheduledEventUpdatedMessage(
IGuildScheduledEvent scheduledEvent, GuildData data, bool early, CancellationToken ct = default) {
var currentUserResult = await _userApi.GetCurrentUserAsync(ct);
if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult);
var embed = new EmbedBuilder();
string? content = null;
if (early)
embed.WithSmallTitle(string.Format(Messages.EventEarlyNotification, scheduledEvent.Name), currentUser)
.WithColour(ColorsList.Default);
else
switch (scheduledEvent.Status) {
case GuildScheduledEventStatus.Active:
data.ScheduledEvents[scheduledEvent.ID.Value].ActualStartTime = DateTimeOffset.UtcNow;
string embedDescription;
switch (scheduledEvent.EntityType) {
case GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice:
if (!scheduledEvent.ChannelID.AsOptional().IsDefined(out var channelId))
return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.ChannelID)));
embedDescription = string.Format(
Messages.DescriptionLocalEventStarted,
Mention.Channel(channelId)
);
break;
case GuildScheduledEventEntityType.External:
if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata))
return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.EntityMetadata)));
if (!scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out var endTime))
return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime)));
if (!metadata.Location.IsDefined(out var location))
return Result.FromError(new ArgumentNullError(nameof(metadata.Location)));
embedDescription = string.Format(
Messages.DescriptionExternalEventStarted,
Markdown.InlineCode(location),
Markdown.Timestamp(endTime)
);
break;
default:
return Result.FromError(new ArgumentOutOfRangeError(nameof(scheduledEvent.EntityType)));
}
var contentResult = await _utility.GetEventNotificationMentions(
scheduledEvent, data.Configuration, ct);
if (!contentResult.IsDefined(out content))
return Result.FromError(contentResult);
embed.WithTitle(string.Format(Messages.EventStarted, scheduledEvent.Name))
.WithDescription(embedDescription)
.WithColour(ColorsList.Green);
break;
case GuildScheduledEventStatus.Completed:
embed.WithTitle(string.Format(Messages.EventCompleted, scheduledEvent.Name))
.WithDescription(
string.Format(
Messages.EventDuration,
DateTimeOffset.UtcNow.Subtract(
data.ScheduledEvents[scheduledEvent.ID.Value].ActualStartTime
?? scheduledEvent.ScheduledStartTime).ToString()))
.WithColour(ColorsList.Black);
data.ScheduledEvents.Remove(scheduledEvent.ID.Value);
break;
case GuildScheduledEventStatus.Canceled:
case GuildScheduledEventStatus.Scheduled:
default: return Result.FromError(new ArgumentOutOfRangeError(nameof(scheduledEvent.Status)));
}
var result = embed.WithCurrentTimestamp().Build();
if (!result.IsDefined(out var built)) return Result.FromError(result);
return (Result)await _channelApi.CreateMessageAsync(
data.Configuration.EventNotificationChannel.ToDiscordSnowflake(),
content ?? default(Optional<string>), embeds: new[] { built }, ct: ct);
}
}