1
0
Fork 1
mirror of https://github.com/TeamOctolings/Octobot.git synced 2025-05-07 06:26:29 +03:00

Split GuildUpdateService into separate services with proper error logging

Signed-off-by: Octol1ttle <l1ttleofficial@outlook.com>
This commit is contained in:
Octol1ttle 2023-08-05 12:14:23 +05:00
parent e9f7825e4a
commit 4d2a1577d7
Signed by: Octol1ttle
GPG key ID: B77C34313AEE1FFF
8 changed files with 708 additions and 650 deletions

View file

@ -1,6 +1,7 @@
using Boyfriend.Commands;
using Boyfriend.Commands.Events;
using Boyfriend.Services;
using Boyfriend.Services.Update;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@ -88,7 +89,9 @@ public sealed class Boyfriend
// Services
.AddSingleton<GuildDataService>()
.AddSingleton<UtilityService>()
.AddHostedService<GuildUpdateService>()
.AddHostedService<MemberUpdateService>()
.AddHostedService<ScheduledEventUpdateService>()
.AddHostedService<SongUpdateService>()
// Slash commands
.AddCommandTree()
.WithCommandGroup<AboutCommandGroup>()

View file

@ -1,7 +1,6 @@
using JetBrains.Annotations;
using Microsoft.Extensions.Logging;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Extensions;
using Remora.Discord.Commands.Services;
using Remora.Results;
@ -31,14 +30,7 @@ public class ErrorLoggingPostExecutionEvent : IPostExecutionEvent
public Task<Result> AfterExecutionAsync(
ICommandContext context, IResult commandResult, CancellationToken ct = default)
{
if (!commandResult.IsSuccess && !commandResult.Error.IsUserOrEnvironmentError())
{
_logger.LogWarning("Error in slash command execution.\n{ErrorMessage}", commandResult.Error.Message);
if (commandResult.Error is ExceptionError exerr)
{
_logger.LogError(exerr.Exception, "An exception has been thrown");
}
}
_logger.LogResult(commandResult, "Error in slash command execution.");
return Task.FromResult(Result.FromSuccess());
}

View file

@ -1,7 +1,6 @@
using JetBrains.Annotations;
using Microsoft.Extensions.Logging;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Extensions;
using Remora.Discord.Commands.Services;
using Remora.Results;
@ -31,14 +30,7 @@ public class LoggingPreparationErrorEvent : IPreparationErrorEvent
public Task<Result> PreparationFailed(
IOperationContext context, IResult preparationResult, CancellationToken ct = default)
{
if (!preparationResult.IsSuccess && !preparationResult.Error.IsUserOrEnvironmentError())
{
_logger.LogWarning("Error in slash command preparation.\n{ErrorMessage}", preparationResult.Error.Message);
if (preparationResult.Error is ExceptionError exerr)
{
_logger.LogError(exerr.Exception, "An exception has been thrown");
}
}
_logger.LogResult(preparationResult, "Error in slash command preparation.");
return Task.FromResult(Result.FromSuccess());
}

View file

@ -1,6 +1,7 @@
using System.Net;
using System.Text;
using DiffPlex.DiffBuilder.Model;
using Microsoft.Extensions.Logging;
using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Objects;
@ -258,4 +259,78 @@ public static class Extensions
return (Result)await feedback.SendContextualEmbedAsync(embed, ct: ct);
}
/// <summary>
/// Checks if the <paramref name="result" /> has failed due to an error that has resulted from neither invalid user
/// input nor the execution environment and logs the error using the provided <paramref name="logger" />.
/// </summary>
/// <remarks>
/// This has special behavior for <see cref="ExceptionError" /> - its exception will be passed to the
/// <paramref name="logger" />
/// </remarks>
/// <param name="logger">The logger to use.</param>
/// <param name="result">The Result whose error check.</param>
/// <param name="message">The message to use if this result has failed.</param>
public static void LogResult(this ILogger logger, IResult result, string? message = "")
{
if (result.IsSuccess || result.Error.IsUserOrEnvironmentError())
{
return;
}
if (result.Error is ExceptionError exe)
{
logger.LogError(exe.Exception, "{UserMessage}\n{ResultErrorMessage}", message, result.Error.Message);
return;
}
logger.LogWarning("{UserMessage}\n{ResultErrorMessage}", message, result.Error.Message);
}
public static void AddIfFailed(this List<Result> list, Result result)
{
if (!result.IsSuccess)
{
list.Add(result);
}
}
/// <summary>
/// Return an appropriate result for a list of failed results. The list must only contain failed results.
/// </summary>
/// <param name="list">The list of failed results.</param>
/// <returns>
/// A successful result if the list is empty, the only Result in the list, or <see cref="AggregateError" />
/// containing all results from the list.
/// </returns>
/// <exception cref="InvalidOperationException"></exception>
public static Result AggregateErrors(this List<Result> list)
{
return list.Count switch
{
0 => Result.FromSuccess(),
1 => list[0],
_ => new AggregateError(list.Cast<IResult>().ToArray())
};
}
public static Result TryGetExternalEventData(this IGuildScheduledEvent scheduledEvent, out DateTimeOffset endTime,
out string? location)
{
endTime = default;
location = default;
if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata))
{
return new ArgumentNullError(nameof(scheduledEvent.EntityMetadata));
}
if (!metadata.Location.IsDefined(out location))
{
return new ArgumentNullError(nameof(metadata.Location));
}
return scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out endTime)
? Result.FromSuccess()
: new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime));
}
}

View file

@ -1,631 +0,0 @@
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
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 sealed partial 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)),
("Jukio Kallio, Daniel Hagström - Fall 'n' Roll", new TimeSpan(0, 3, 14)),
("SCATTLE - Hypertension", new TimeSpan(0, 3, 18)),
("KEYGEN CHURCH - Tenebre Rosso Sangue", new TimeSpan(0, 3, 53)),
("Chipzel - Swing Me Another 6", new TimeSpan(0, 5, 32)),
("Noisecream - Mist of Rage", new TimeSpan(0, 2, 25))
};
private static readonly string[] GenericNicknames =
{
"Albatross", "Alpha", "Anchor", "Banjo", "Bell", "Beta", "Blackbird", "Bulldog", "Canary",
"Cat", "Calf", "Cyclone", "Daisy", "Dalmatian", "Dart", "Delta", "Diamond", "Donkey", "Duck",
"Emu", "Eclipse", "Flamingo", "Flute", "Frog", "Goose", "Hatchet", "Heron", "Husky", "Hurricane",
"Iceberg", "Iguana", "Kiwi", "Kite", "Lamb", "Lily", "Macaw", "Manatee", "Maple", "Mask",
"Nautilus", "Ostrich", "Octopus", "Pelican", "Puffin", "Pyramid", "Rattle", "Robin", "Rose",
"Salmon", "Seal", "Shark", "Sheep", "Snake", "Sonar", "Stump", "Sparrow", "Toaster", "Toucan",
"Torus", "Violet", "Vortex", "Vulture", "Wagon", "Whale", "Woodpecker", "Zebra", "Zigzag"
};
private readonly List<Activity> _activityList = new(1) { new Activity("with Remora.Discord", ActivityType.Game) };
private readonly IDiscordRestChannelAPI _channelApi;
private readonly DiscordGatewayClient _client;
private readonly IDiscordRestGuildScheduledEventAPI _eventApi;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly GuildDataService _guildData;
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 guildData,
IDiscordRestGuildScheduledEventAPI eventApi, IDiscordRestGuildAPI guildApi, ILogger<GuildUpdateService> logger,
IDiscordRestUserAPI userApi, UtilityService utility)
{
_channelApi = channelApi;
_client = client;
_guildData = guildData;
_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 = _guildData.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="GuildSettings.DefaultRole" /> if one is set.</item>
/// <item>Sends reminders about an upcoming scheduled event.</item>
/// <item>Automatically starts scheduled events if <see cref="GuildSettings.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 _guildData.GetData(guildId, ct);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
var defaultRole = GuildSettings.DefaultRole.Get(data.Settings);
foreach (var memberData in data.MemberData.Values)
{
var guildMemberResult = await _guildApi.GetGuildMemberAsync(guildId, memberData.Id.ToSnowflake(), ct);
if (!guildMemberResult.IsDefined(out var guildMember))
{
return;
}
if (!guildMember.User.IsDefined(out var user))
{
return;
}
await TickMemberAsync(guildId, user, guildMember, memberData, defaultRole, data.Settings, ct);
}
var eventsResult = await _eventApi.ListScheduledEventsForGuildAsync(guildId, ct: ct);
if (!eventsResult.IsSuccess)
{
_logger.LogWarning("Error retrieving scheduled events.\n{ErrorMessage}", eventsResult.Error.Message);
return;
}
if (!GuildSettings.EventNotificationChannel.Get(data.Settings).Empty())
{
await TickScheduledEventsAsync(guildId, data, eventsResult.Entity, ct);
}
}
private async Task TickScheduledEventsAsync(
Snowflake guildId, GuildData data, IEnumerable<IGuildScheduledEvent> events, CancellationToken ct)
{
foreach (var scheduledEvent in events)
{
if (!data.ScheduledEvents.ContainsKey(scheduledEvent.ID.Value))
{
data.ScheduledEvents.Add(scheduledEvent.ID.Value, new ScheduledEventData(scheduledEvent.Status));
}
var storedEvent = data.ScheduledEvents[scheduledEvent.ID.Value];
if (storedEvent.Status == scheduledEvent.Status)
{
await TickScheduledEventAsync(guildId, data, scheduledEvent, storedEvent, ct);
continue;
}
storedEvent.Status = scheduledEvent.Status;
var statusChangedResponseResult = storedEvent.Status switch
{
GuildScheduledEventStatus.Scheduled =>
await SendScheduledEventCreatedMessage(scheduledEvent, data.Settings, ct),
GuildScheduledEventStatus.Active or GuildScheduledEventStatus.Completed =>
await SendScheduledEventUpdatedMessage(scheduledEvent, data, ct),
_ => new ArgumentOutOfRangeError(nameof(scheduledEvent.Status))
};
if (!statusChangedResponseResult.IsSuccess)
{
_logger.LogWarning(
"Error handling scheduled event status update.\n{ErrorMessage}",
statusChangedResponseResult.Error.Message);
}
}
}
private async Task TickScheduledEventAsync(
Snowflake guildId, GuildData data, IGuildScheduledEvent scheduledEvent, ScheduledEventData eventData,
CancellationToken ct)
{
if (DateTimeOffset.UtcNow >= scheduledEvent.ScheduledStartTime)
{
await TryAutoStartEventAsync(guildId, data, scheduledEvent, ct);
return;
}
if (GuildSettings.EventEarlyNotificationOffset.Get(data.Settings) == TimeSpan.Zero
|| eventData.EarlyNotificationSent
|| DateTimeOffset.UtcNow
< scheduledEvent.ScheduledStartTime
- GuildSettings.EventEarlyNotificationOffset.Get(data.Settings))
{
return;
}
var earlyResult = await SendEarlyEventNotificationAsync(scheduledEvent, data, ct);
if (earlyResult.IsSuccess)
{
eventData.EarlyNotificationSent = true;
return;
}
_logger.LogWarning(
"Error in scheduled event early notification sender.\n{ErrorMessage}",
earlyResult.Error.Message);
}
private async Task TryAutoStartEventAsync(
Snowflake guildId, GuildData data, IGuildScheduledEvent scheduledEvent, CancellationToken ct)
{
if (GuildSettings.AutoStartEvents.Get(data.Settings)
&& 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);
}
}
}
private async Task TickMemberAsync(
Snowflake guildId, IUser user, IGuildMember member, MemberData memberData, Snowflake defaultRole,
JsonNode cfg, CancellationToken ct)
{
if (defaultRole.Value is not 0 && !memberData.Roles.Contains(defaultRole.Value))
{
_ = _guildApi.AddGuildMemberRoleAsync(
guildId, user.ID, defaultRole, ct: ct);
}
if (DateTimeOffset.UtcNow > memberData.BannedUntil)
{
var unbanResult = await _guildApi.RemoveGuildBanAsync(
guildId, user.ID, Messages.PunishmentExpired.EncodeHeader(), ct);
if (!unbanResult.IsSuccess)
{
_logger.LogWarning(
"Error in automatic user unban request.\n{ErrorMessage}", unbanResult.Error.Message);
return;
}
memberData.BannedUntil = null;
}
for (var i = memberData.Reminders.Count - 1; i >= 0; i--)
{
await TickReminderAsync(memberData.Reminders[i], user, memberData, ct);
}
if (GuildSettings.RenameHoistedUsers.Get(cfg))
{
await FilterNicknameAsync(guildId, user, member, ct);
}
}
private Task FilterNicknameAsync(Snowflake guildId, IUser user, IGuildMember member, CancellationToken ct)
{
var currentNickname = member.Nickname.IsDefined(out var nickname)
? nickname
: user.GlobalName ?? user.Username;
var characterList = currentNickname.ToList();
var usernameChanged = false;
foreach (var character in currentNickname)
{
if (IllegalChars().IsMatch(character.ToString()))
{
characterList.Remove(character);
usernameChanged = true;
continue;
}
break;
}
if (!usernameChanged)
{
return Task.CompletedTask;
}
var newNickname = string.Concat(characterList.ToArray());
_ = _guildApi.ModifyGuildMemberAsync(
guildId, user.ID,
!string.IsNullOrWhiteSpace(newNickname)
? newNickname
: GenericNicknames[Random.Shared.Next(GenericNicknames.Length)],
ct: ct);
return Task.CompletedTask;
}
[GeneratedRegex("[^0-9A-zЁА-яё]")]
private static partial Regex IllegalChars();
private async Task TickReminderAsync(Reminder reminder, IUser user, MemberData memberData, CancellationToken ct)
{
if (DateTimeOffset.UtcNow < reminder.At)
{
return;
}
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))
{
return;
}
var messageResult = await _channelApi.CreateMessageAsync(
reminder.Channel.ToSnowflake(), 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);
}
/// <summary>
/// Handles sending a notification, mentioning the <see cref="GuildSettings.EventNotificationRole" /> if one is
/// set,
/// when a scheduled event is created
/// in a guild's <see cref="GuildSettings.EventNotificationChannel" /> if one is set.
/// </summary>
/// <param name="scheduledEvent">The scheduled event that has just been created.</param>
/// <param name="settings">The settings 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, JsonNode settings, CancellationToken ct = default)
{
if (!scheduledEvent.Creator.IsDefined(out var creator))
{
return new ArgumentNullError(nameof(scheduledEvent.Creator));
}
Result<string> embedDescriptionResult;
var eventDescription = scheduledEvent.Description is { HasValue: true, Value: not null }
? scheduledEvent.Description.Value
: string.Empty;
embedDescriptionResult = scheduledEvent.EntityType switch
{
GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice =>
GetLocalEventCreatedEmbedDescription(scheduledEvent, eventDescription),
GuildScheduledEventEntityType.External => GetExternalScheduledEventCreatedEmbedDescription(
scheduledEvent, eventDescription),
_ => new ArgumentOutOfRangeError(nameof(scheduledEvent.EntityType))
};
if (!embedDescriptionResult.IsDefined(out var embedDescription))
{
return Result.FromError(embedDescriptionResult);
}
var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(Messages.EventCreatedTitle, creator.GetTag()), creator)
.WithTitle(scheduledEvent.Name)
.WithDescription(embedDescription)
.WithEventCover(scheduledEvent.ID, scheduledEvent.Image)
.WithCurrentTimestamp()
.WithColour(ColorsList.White)
.Build();
if (!embed.IsDefined(out var built))
{
return Result.FromError(embed);
}
var roleMention = !GuildSettings.EventNotificationRole.Get(settings).Empty()
? Mention.Role(GuildSettings.EventNotificationRole.Get(settings))
: 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(
GuildSettings.EventNotificationChannel.Get(settings), roleMention, embeds: new[] { built },
components: new[] { new ActionRowComponent(new[] { button }) }, ct: ct);
}
private static Result<string> GetExternalScheduledEventCreatedEmbedDescription(
IGuildScheduledEvent scheduledEvent, string eventDescription)
{
Result<string> embedDescription;
if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata))
{
return new ArgumentNullError(nameof(scheduledEvent.EntityMetadata));
}
if (!scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out var endTime))
{
return new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime));
}
if (!metadata.Location.IsDefined(out var location))
{
return 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)
))}";
return embedDescription;
}
private static Result<string> GetLocalEventCreatedEmbedDescription(
IGuildScheduledEvent scheduledEvent, string eventDescription)
{
if (!scheduledEvent.ChannelID.AsOptional().IsDefined(out var channelId))
{
return new ArgumentNullError(nameof(scheduledEvent.ChannelID));
}
return $"{eventDescription}\n\n{Markdown.BlockQuote(
string.Format(
Messages.DescriptionLocalEventCreated,
Markdown.Timestamp(scheduledEvent.ScheduledStartTime),
Mention.Channel(channelId)
))}";
}
/// <summary>
/// Handles sending a notification, mentioning the <see cref="GuildSettings.EventNotificationRole" /> and event
/// subscribers,
/// when a scheduled event has started or completed
/// in a guild's <see cref="GuildSettings.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="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, CancellationToken ct = default)
{
if (scheduledEvent.Status == GuildScheduledEventStatus.Active)
{
data.ScheduledEvents[scheduledEvent.ID.Value].ActualStartTime = DateTimeOffset.UtcNow;
var embedDescriptionResult = scheduledEvent.EntityType switch
{
GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice =>
GetLocalEventStartedEmbedDescription(scheduledEvent),
GuildScheduledEventEntityType.External => GetExternalEventStartedEmbedDescription(scheduledEvent),
_ => new ArgumentOutOfRangeError(nameof(scheduledEvent.EntityType))
};
var contentResult = await _utility.GetEventNotificationMentions(
scheduledEvent, data.Settings, ct);
if (!contentResult.IsDefined(out var content))
{
return Result.FromError(contentResult);
}
if (!embedDescriptionResult.IsDefined(out var embedDescription))
{
return Result.FromError(embedDescriptionResult);
}
var startedEmbed = new EmbedBuilder().WithTitle(string.Format(Messages.EventStarted, scheduledEvent.Name))
.WithDescription(embedDescription)
.WithColour(ColorsList.Green)
.WithCurrentTimestamp()
.Build();
if (!startedEmbed.IsDefined(out var startedBuilt))
{
return Result.FromError(startedEmbed);
}
return (Result)await _channelApi.CreateMessageAsync(
GuildSettings.EventNotificationChannel.Get(data.Settings),
content, embeds: new[] { startedBuilt }, ct: ct);
}
if (scheduledEvent.Status != GuildScheduledEventStatus.Completed)
{
return new ArgumentOutOfRangeError(nameof(scheduledEvent.Status));
}
data.ScheduledEvents.Remove(scheduledEvent.ID.Value);
var completedEmbed = new EmbedBuilder().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)
.WithCurrentTimestamp()
.Build();
if (!completedEmbed.IsDefined(out var completedBuilt))
{
return Result.FromError(completedEmbed);
}
return (Result)await _channelApi.CreateMessageAsync(
GuildSettings.EventNotificationChannel.Get(data.Settings),
embeds: new[] { completedBuilt }, ct: ct);
}
private static Result<string> GetLocalEventStartedEmbedDescription(IGuildScheduledEvent scheduledEvent)
{
Result<string> embedDescription;
if (!scheduledEvent.ChannelID.AsOptional().IsDefined(out var channelId))
{
return new ArgumentNullError(nameof(scheduledEvent.ChannelID));
}
embedDescription = string.Format(
Messages.DescriptionLocalEventStarted,
Mention.Channel(channelId)
);
return embedDescription;
}
private static Result<string> GetExternalEventStartedEmbedDescription(IGuildScheduledEvent scheduledEvent)
{
Result<string> embedDescription;
if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata))
{
return new ArgumentNullError(nameof(scheduledEvent.EntityMetadata));
}
if (!scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out var endTime))
{
return new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime));
}
if (!metadata.Location.IsDefined(out var location))
{
return new ArgumentNullError(nameof(metadata.Location));
}
embedDescription = string.Format(
Messages.DescriptionExternalEventStarted,
Markdown.InlineCode(location),
Markdown.Timestamp(endTime)
);
return embedDescription;
}
private async Task<Result> SendEarlyEventNotificationAsync(
IGuildScheduledEvent scheduledEvent, GuildData data, CancellationToken ct)
{
var currentUserResult = await _userApi.GetCurrentUserAsync(ct);
if (!currentUserResult.IsDefined(out var currentUser))
{
return Result.FromError(currentUserResult);
}
var contentResult = await _utility.GetEventNotificationMentions(
scheduledEvent, data.Settings, ct);
if (!contentResult.IsDefined(out var content))
{
return Result.FromError(contentResult);
}
var earlyResult = new EmbedBuilder()
.WithSmallTitle(string.Format(Messages.EventEarlyNotification, scheduledEvent.Name), currentUser)
.WithColour(ColorsList.Default)
.WithCurrentTimestamp()
.Build();
if (!earlyResult.IsDefined(out var earlyBuilt))
{
return Result.FromError(earlyResult);
}
return (Result)await _channelApi.CreateMessageAsync(
GuildSettings.EventNotificationChannel.Get(data.Settings),
content,
embeds: new[] { earlyBuilt }, ct: ct);
}
}

View file

@ -0,0 +1,193 @@
using System.Text.RegularExpressions;
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.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core;
using Remora.Results;
namespace Boyfriend.Services.Update;
public sealed partial class MemberUpdateService : BackgroundService
{
private static readonly string[] GenericNicknames =
{
"Albatross", "Alpha", "Anchor", "Banjo", "Bell", "Beta", "Blackbird", "Bulldog", "Canary",
"Cat", "Calf", "Cyclone", "Daisy", "Dalmatian", "Dart", "Delta", "Diamond", "Donkey", "Duck",
"Emu", "Eclipse", "Flamingo", "Flute", "Frog", "Goose", "Hatchet", "Heron", "Husky", "Hurricane",
"Iceberg", "Iguana", "Kiwi", "Kite", "Lamb", "Lily", "Macaw", "Manatee", "Maple", "Mask",
"Nautilus", "Ostrich", "Octopus", "Pelican", "Puffin", "Pyramid", "Rattle", "Robin", "Rose",
"Salmon", "Seal", "Shark", "Sheep", "Snake", "Sonar", "Stump", "Sparrow", "Toaster", "Toucan",
"Torus", "Violet", "Vortex", "Vulture", "Wagon", "Whale", "Woodpecker", "Zebra", "Zigzag"
};
private readonly IDiscordRestChannelAPI _channelApi;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly GuildDataService _guildData;
private readonly ILogger<MemberUpdateService> _logger;
public MemberUpdateService(IDiscordRestChannelAPI channelApi, IDiscordRestGuildAPI guildApi,
GuildDataService guildData, ILogger<MemberUpdateService> logger)
{
_channelApi = channelApi;
_guildApi = guildApi;
_guildData = guildData;
_logger = logger;
}
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 = _guildData.GetGuildIds();
tasks.AddRange(guildIds.Select(async id =>
{
var tickResult = await TickMemberDatasAsync(id, ct);
_logger.LogResult(tickResult);
}));
await Task.WhenAll(tasks);
tasks.Clear();
}
}
private async Task<Result> TickMemberDatasAsync(Snowflake guildId, CancellationToken ct)
{
var guildData = await _guildData.GetData(guildId, ct);
var defaultRole = GuildSettings.DefaultRole.Get(guildData.Settings);
var failedResults = new List<Result>();
foreach (var data in guildData.MemberData.Values)
{
var tickResult = await TickMemberDataAsync(guildId, guildData, defaultRole, data, ct);
failedResults.AddIfFailed(tickResult);
}
return failedResults.AggregateErrors();
}
private async Task<Result> TickMemberDataAsync(Snowflake guildId, GuildData guildData, Snowflake defaultRole,
MemberData data,
CancellationToken ct)
{
var failedResults = new List<Result>();
var id = data.Id.ToSnowflake();
if (DateTimeOffset.UtcNow > data.BannedUntil)
{
var unbanResult = await _guildApi.RemoveGuildBanAsync(
guildId, id, Messages.PunishmentExpired.EncodeHeader(), ct);
if (unbanResult.IsSuccess)
{
data.BannedUntil = null;
}
return unbanResult;
}
if (defaultRole.Value is not 0 && !data.Roles.Contains(defaultRole.Value))
{
var addResult = await _guildApi.AddGuildMemberRoleAsync(
guildId, id, defaultRole, ct: ct);
failedResults.AddIfFailed(addResult);
}
var guildMemberResult = await _guildApi.GetGuildMemberAsync(guildId, id, ct);
if (!guildMemberResult.IsDefined(out var guildMember))
{
return failedResults.AggregateErrors();
}
if (!guildMember.User.IsDefined(out var user))
{
return new ArgumentNullError(nameof(guildMember.User));
}
for (var i = data.Reminders.Count - 1; i >= 0; i--)
{
var reminderTickResult = await TickReminderAsync(data.Reminders[i], user, data, ct);
failedResults.AddIfFailed(reminderTickResult);
}
if (GuildSettings.RenameHoistedUsers.Get(guildData.Settings))
{
var filterResult = await FilterNicknameAsync(guildId, user, guildMember, ct);
failedResults.AddIfFailed(filterResult);
}
return failedResults.AggregateErrors();
}
private async Task<Result> FilterNicknameAsync(Snowflake guildId, IUser user, IGuildMember member,
CancellationToken ct)
{
var currentNickname = member.Nickname.IsDefined(out var nickname)
? nickname
: user.GlobalName ?? user.Username;
var characterList = currentNickname.ToList();
var usernameChanged = false;
foreach (var character in currentNickname)
{
if (IllegalChars().IsMatch(character.ToString()))
{
characterList.Remove(character);
usernameChanged = true;
continue;
}
break;
}
if (!usernameChanged)
{
return Result.FromSuccess();
}
var newNickname = string.Concat(characterList.ToArray());
return await _guildApi.ModifyGuildMemberAsync(
guildId, user.ID,
!string.IsNullOrWhiteSpace(newNickname)
? newNickname
: GenericNicknames[Random.Shared.Next(GenericNicknames.Length)],
ct: ct);
}
[GeneratedRegex("[^0-9A-zЁА-яё]")]
private static partial Regex IllegalChars();
private async Task<Result> TickReminderAsync(Reminder reminder, IUser user, MemberData data, CancellationToken ct)
{
if (DateTimeOffset.UtcNow < reminder.At)
{
return Result.FromSuccess();
}
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))
{
return Result.FromError(embed);
}
var messageResult = await _channelApi.CreateMessageAsync(
reminder.Channel.ToSnowflake(), Mention.User(user), embeds: new[] { built }, ct: ct);
if (!messageResult.IsSuccess)
{
return Result.FromError(messageResult);
}
data.Reminders.Remove(reminder);
return Result.FromSuccess();
}
}

View file

@ -0,0 +1,368 @@
using System.Text.Json.Nodes;
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.Objects;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Discord.Interactivity;
using Remora.Rest.Core;
using Remora.Results;
namespace Boyfriend.Services.Update;
public sealed class ScheduledEventUpdateService : BackgroundService
{
private readonly IDiscordRestChannelAPI _channelApi;
private readonly IDiscordRestGuildScheduledEventAPI _eventApi;
private readonly GuildDataService _guildData;
private readonly ILogger<ScheduledEventUpdateService> _logger;
private readonly IDiscordRestUserAPI _userApi;
private readonly UtilityService _utility;
public ScheduledEventUpdateService(IDiscordRestChannelAPI channelApi, IDiscordRestGuildScheduledEventAPI eventApi,
GuildDataService guildData, ILogger<ScheduledEventUpdateService> logger, IDiscordRestUserAPI userApi,
UtilityService utility)
{
_channelApi = channelApi;
_eventApi = eventApi;
_guildData = guildData;
_logger = logger;
_userApi = userApi;
_utility = utility;
}
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 = _guildData.GetGuildIds();
tasks.AddRange(guildIds.Select(async id =>
{
var tickResult = await TickScheduledEventsAsync(id, ct);
_logger.LogResult(tickResult);
}));
await Task.WhenAll(tasks);
tasks.Clear();
}
}
private async Task<Result> TickScheduledEventsAsync(Snowflake guildId, CancellationToken ct)
{
var failedResults = new List<Result>();
var data = await _guildData.GetData(guildId, ct);
var eventsResult = await _eventApi.ListScheduledEventsForGuildAsync(guildId, ct: ct);
if (!eventsResult.IsDefined(out var events))
{
return Result.FromError(eventsResult);
}
foreach (var scheduledEvent in events)
{
if (!data.ScheduledEvents.ContainsKey(scheduledEvent.ID.Value))
{
data.ScheduledEvents.Add(scheduledEvent.ID.Value, new ScheduledEventData(scheduledEvent.Status));
}
var storedEvent = data.ScheduledEvents[scheduledEvent.ID.Value];
if (storedEvent.Status == scheduledEvent.Status)
{
var tickResult = await TickScheduledEventAsync(guildId, data, scheduledEvent, storedEvent, ct);
failedResults.AddIfFailed(tickResult);
continue;
}
storedEvent.Status = scheduledEvent.Status;
var statusChangedResponseResult = storedEvent.Status switch
{
GuildScheduledEventStatus.Scheduled =>
await SendScheduledEventCreatedMessage(scheduledEvent, data.Settings, ct),
GuildScheduledEventStatus.Active or GuildScheduledEventStatus.Completed =>
await SendScheduledEventUpdatedMessage(scheduledEvent, data, ct),
_ => new ArgumentOutOfRangeError(nameof(scheduledEvent.Status))
};
failedResults.AddIfFailed(statusChangedResponseResult);
}
return failedResults.AggregateErrors();
}
private async Task<Result> TickScheduledEventAsync(
Snowflake guildId, GuildData data, IGuildScheduledEvent scheduledEvent, ScheduledEventData eventData,
CancellationToken ct)
{
if (GuildSettings.AutoStartEvents.Get(data.Settings)
&& DateTimeOffset.UtcNow >= scheduledEvent.ScheduledStartTime
&& scheduledEvent.Status is not GuildScheduledEventStatus.Active)
{
return await AutoStartEventAsync(guildId, scheduledEvent, ct);
}
var offset = GuildSettings.EventEarlyNotificationOffset.Get(data.Settings);
if (offset == TimeSpan.Zero
|| eventData.EarlyNotificationSent
|| DateTimeOffset.UtcNow < scheduledEvent.ScheduledStartTime - offset)
{
return Result.FromSuccess();
}
return await SendEarlyEventNotificationAsync(scheduledEvent, data, ct);
}
private async Task<Result> AutoStartEventAsync(
Snowflake guildId, IGuildScheduledEvent scheduledEvent, CancellationToken ct)
{
return (Result)await _eventApi.ModifyGuildScheduledEventAsync(
guildId, scheduledEvent.ID,
status: GuildScheduledEventStatus.Active, ct: ct);
}
/// <summary>
/// Handles sending a notification, mentioning the <see cref="GuildSettings.EventNotificationRole" /> if one is
/// set,
/// when a scheduled event is created
/// in a guild's <see cref="GuildSettings.EventNotificationChannel" /> if one is set.
/// </summary>
/// <param name="scheduledEvent">The scheduled event that has just been created.</param>
/// <param name="settings">The settings 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, JsonNode settings, CancellationToken ct = default)
{
if (!scheduledEvent.Creator.IsDefined(out var creator))
{
return new ArgumentNullError(nameof(scheduledEvent.Creator));
}
var eventDescription = scheduledEvent.Description.IsDefined(out var description)
? description
: string.Empty;
var embedDescriptionResult = scheduledEvent.EntityType switch
{
GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice =>
GetLocalEventCreatedEmbedDescription(scheduledEvent, eventDescription),
GuildScheduledEventEntityType.External => GetExternalScheduledEventCreatedEmbedDescription(
scheduledEvent, eventDescription),
_ => new ArgumentOutOfRangeError(nameof(scheduledEvent.EntityType))
};
if (!embedDescriptionResult.IsDefined(out var embedDescription))
{
return Result.FromError(embedDescriptionResult);
}
var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(Messages.EventCreatedTitle, creator.GetTag()), creator)
.WithTitle(scheduledEvent.Name)
.WithDescription(embedDescription)
.WithEventCover(scheduledEvent.ID, scheduledEvent.Image)
.WithCurrentTimestamp()
.WithColour(ColorsList.White)
.Build();
if (!embed.IsDefined(out var built))
{
return Result.FromError(embed);
}
var roleMention = !GuildSettings.EventNotificationRole.Get(settings).Empty()
? Mention.Role(GuildSettings.EventNotificationRole.Get(settings))
: 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(
GuildSettings.EventNotificationChannel.Get(settings), roleMention, embeds: new[] { built },
components: new[] { new ActionRowComponent(new[] { button }) }, ct: ct);
}
private static Result<string> GetExternalScheduledEventCreatedEmbedDescription(
IGuildScheduledEvent scheduledEvent, string eventDescription)
{
var dataResult = scheduledEvent.TryGetExternalEventData(out var endTime, out var location);
if (!dataResult.IsSuccess)
{
return Result<string>.FromError(dataResult);
}
return $"{eventDescription}\n\n{Markdown.BlockQuote(
string.Format(
Messages.DescriptionExternalEventCreated,
Markdown.Timestamp(scheduledEvent.ScheduledStartTime),
Markdown.Timestamp(endTime),
Markdown.InlineCode(location ?? string.Empty)
))}";
}
private static Result<string> GetLocalEventCreatedEmbedDescription(
IGuildScheduledEvent scheduledEvent, string eventDescription)
{
if (scheduledEvent.ChannelID is null)
{
return new ArgumentNullError(nameof(scheduledEvent.ChannelID));
}
return $"{eventDescription}\n\n{Markdown.BlockQuote(
string.Format(
Messages.DescriptionLocalEventCreated,
Markdown.Timestamp(scheduledEvent.ScheduledStartTime),
Mention.Channel(scheduledEvent.ChannelID.Value)
))}";
}
/// <summary>
/// Handles sending a notification, mentioning the <see cref="GuildSettings.EventNotificationRole" /> and event
/// subscribers,
/// when a scheduled event has started or completed
/// in a guild's <see cref="GuildSettings.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="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, CancellationToken ct = default)
{
if (scheduledEvent.Status == GuildScheduledEventStatus.Active)
{
data.ScheduledEvents[scheduledEvent.ID.Value].ActualStartTime = DateTimeOffset.UtcNow;
var embedDescriptionResult = scheduledEvent.EntityType switch
{
GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice =>
GetLocalEventStartedEmbedDescription(scheduledEvent),
GuildScheduledEventEntityType.External => GetExternalEventStartedEmbedDescription(scheduledEvent),
_ => new ArgumentOutOfRangeError(nameof(scheduledEvent.EntityType))
};
var contentResult = await _utility.GetEventNotificationMentions(
scheduledEvent, data.Settings, ct);
if (!contentResult.IsDefined(out var content))
{
return Result.FromError(contentResult);
}
if (!embedDescriptionResult.IsDefined(out var embedDescription))
{
return Result.FromError(embedDescriptionResult);
}
var startedEmbed = new EmbedBuilder().WithTitle(string.Format(Messages.EventStarted, scheduledEvent.Name))
.WithDescription(embedDescription)
.WithColour(ColorsList.Green)
.WithCurrentTimestamp()
.Build();
if (!startedEmbed.IsDefined(out var startedBuilt))
{
return Result.FromError(startedEmbed);
}
return (Result)await _channelApi.CreateMessageAsync(
GuildSettings.EventNotificationChannel.Get(data.Settings),
content, embeds: new[] { startedBuilt }, ct: ct);
}
if (scheduledEvent.Status != GuildScheduledEventStatus.Completed)
{
return new ArgumentOutOfRangeError(nameof(scheduledEvent.Status));
}
data.ScheduledEvents.Remove(scheduledEvent.ID.Value);
var completedEmbed = new EmbedBuilder().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)
.WithCurrentTimestamp()
.Build();
if (!completedEmbed.IsDefined(out var completedBuilt))
{
return Result.FromError(completedEmbed);
}
return (Result)await _channelApi.CreateMessageAsync(
GuildSettings.EventNotificationChannel.Get(data.Settings),
embeds: new[] { completedBuilt }, ct: ct);
}
private static Result<string> GetLocalEventStartedEmbedDescription(IGuildScheduledEvent scheduledEvent)
{
if (scheduledEvent.ChannelID is null)
{
return new ArgumentNullError(nameof(scheduledEvent.ChannelID));
}
return string.Format(
Messages.DescriptionLocalEventStarted,
Mention.Channel(scheduledEvent.ChannelID.Value)
);
}
private static Result<string> GetExternalEventStartedEmbedDescription(IGuildScheduledEvent scheduledEvent)
{
var dataResult = scheduledEvent.TryGetExternalEventData(out var endTime, out var location);
if (!dataResult.IsSuccess)
{
return Result<string>.FromError(dataResult);
}
return string.Format(
Messages.DescriptionExternalEventStarted,
Markdown.InlineCode(location ?? string.Empty),
Markdown.Timestamp(endTime)
);
}
private async Task<Result> SendEarlyEventNotificationAsync(
IGuildScheduledEvent scheduledEvent, GuildData data, CancellationToken ct)
{
var currentUserResult = await _userApi.GetCurrentUserAsync(ct);
if (!currentUserResult.IsDefined(out var currentUser))
{
return Result.FromError(currentUserResult);
}
var contentResult = await _utility.GetEventNotificationMentions(
scheduledEvent, data.Settings, ct);
if (!contentResult.IsDefined(out var content))
{
return Result.FromError(contentResult);
}
var earlyResult = new EmbedBuilder()
.WithSmallTitle(string.Format(Messages.EventEarlyNotification, scheduledEvent.Name), currentUser)
.WithColour(ColorsList.Default)
.WithCurrentTimestamp()
.Build();
if (!earlyResult.IsDefined(out var earlyBuilt))
{
return Result.FromError(earlyResult);
}
return (Result)await _channelApi.CreateMessageAsync(
GuildSettings.EventNotificationChannel.Get(data.Settings),
content,
embeds: new[] { earlyBuilt }, ct: ct);
}
}

View file

@ -0,0 +1,66 @@
using Microsoft.Extensions.Hosting;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Gateway.Commands;
using Remora.Discord.API.Objects;
using Remora.Discord.Gateway;
namespace Boyfriend.Services.Update;
public sealed class SongUpdateService : 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)),
("Jukio Kallio, Daniel Hagström - Fall 'n' Roll", new TimeSpan(0, 3, 14)),
("SCATTLE - Hypertension", new TimeSpan(0, 3, 18)),
("KEYGEN CHURCH - Tenebre Rosso Sangue", new TimeSpan(0, 3, 53)),
("Chipzel - Swing Me Another 6", new TimeSpan(0, 5, 32)),
("Noisecream - Mist of Rage", new TimeSpan(0, 2, 25))
};
private readonly List<Activity> _activityList = new(1)
{
new Activity("with Remora.Discord", ActivityType.Game)
};
private readonly DiscordGatewayClient _client;
private readonly GuildDataService _guildData;
private uint _nextSongIndex;
public SongUpdateService(DiscordGatewayClient client, GuildDataService guildData)
{
_client = client;
_guildData = guildData;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (_guildData.GetGuildIds().Count is 0)
{
await Task.Delay(TimeSpan.FromSeconds(5), ct);
}
while (!ct.IsCancellationRequested)
{
var nextSong = SongList[_nextSongIndex];
_activityList[0] = new Activity(nextSong.Name, ActivityType.Listening);
_client.SubmitCommand(
new UpdatePresence(
UserStatus.Online, false, DateTimeOffset.UtcNow, _activityList));
_nextSongIndex++;
if (_nextSongIndex >= SongList.Length)
{
_nextSongIndex = 0;
}
await Task.Delay(nextSong.Duration, ct);
}
}
}