mirror of
https://github.com/TeamOctolings/Octobot.git
synced 2025-05-12 00:43:15 +03:00
Split GuildUpdateService into separate services with proper error logging
Signed-off-by: Octol1ttle <l1ttleofficial@outlook.com>
This commit is contained in:
parent
e9f7825e4a
commit
4d2a1577d7
8 changed files with 708 additions and 650 deletions
|
@ -1,6 +1,7 @@
|
||||||
using Boyfriend.Commands;
|
using Boyfriend.Commands;
|
||||||
using Boyfriend.Commands.Events;
|
using Boyfriend.Commands.Events;
|
||||||
using Boyfriend.Services;
|
using Boyfriend.Services;
|
||||||
|
using Boyfriend.Services.Update;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
@ -88,7 +89,9 @@ public sealed class Boyfriend
|
||||||
// Services
|
// Services
|
||||||
.AddSingleton<GuildDataService>()
|
.AddSingleton<GuildDataService>()
|
||||||
.AddSingleton<UtilityService>()
|
.AddSingleton<UtilityService>()
|
||||||
.AddHostedService<GuildUpdateService>()
|
.AddHostedService<MemberUpdateService>()
|
||||||
|
.AddHostedService<ScheduledEventUpdateService>()
|
||||||
|
.AddHostedService<SongUpdateService>()
|
||||||
// Slash commands
|
// Slash commands
|
||||||
.AddCommandTree()
|
.AddCommandTree()
|
||||||
.WithCommandGroup<AboutCommandGroup>()
|
.WithCommandGroup<AboutCommandGroup>()
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
using JetBrains.Annotations;
|
using JetBrains.Annotations;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Remora.Discord.Commands.Contexts;
|
using Remora.Discord.Commands.Contexts;
|
||||||
using Remora.Discord.Commands.Extensions;
|
|
||||||
using Remora.Discord.Commands.Services;
|
using Remora.Discord.Commands.Services;
|
||||||
using Remora.Results;
|
using Remora.Results;
|
||||||
|
|
||||||
|
@ -31,14 +30,7 @@ public class ErrorLoggingPostExecutionEvent : IPostExecutionEvent
|
||||||
public Task<Result> AfterExecutionAsync(
|
public Task<Result> AfterExecutionAsync(
|
||||||
ICommandContext context, IResult commandResult, CancellationToken ct = default)
|
ICommandContext context, IResult commandResult, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (!commandResult.IsSuccess && !commandResult.Error.IsUserOrEnvironmentError())
|
_logger.LogResult(commandResult, "Error in slash command execution.");
|
||||||
{
|
|
||||||
_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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Task.FromResult(Result.FromSuccess());
|
return Task.FromResult(Result.FromSuccess());
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
using JetBrains.Annotations;
|
using JetBrains.Annotations;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Remora.Discord.Commands.Contexts;
|
using Remora.Discord.Commands.Contexts;
|
||||||
using Remora.Discord.Commands.Extensions;
|
|
||||||
using Remora.Discord.Commands.Services;
|
using Remora.Discord.Commands.Services;
|
||||||
using Remora.Results;
|
using Remora.Results;
|
||||||
|
|
||||||
|
@ -31,14 +30,7 @@ public class LoggingPreparationErrorEvent : IPreparationErrorEvent
|
||||||
public Task<Result> PreparationFailed(
|
public Task<Result> PreparationFailed(
|
||||||
IOperationContext context, IResult preparationResult, CancellationToken ct = default)
|
IOperationContext context, IResult preparationResult, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (!preparationResult.IsSuccess && !preparationResult.Error.IsUserOrEnvironmentError())
|
_logger.LogResult(preparationResult, "Error in slash command preparation.");
|
||||||
{
|
|
||||||
_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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Task.FromResult(Result.FromSuccess());
|
return Task.FromResult(Result.FromSuccess());
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using DiffPlex.DiffBuilder.Model;
|
using DiffPlex.DiffBuilder.Model;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Remora.Discord.API;
|
using Remora.Discord.API;
|
||||||
using Remora.Discord.API.Abstractions.Objects;
|
using Remora.Discord.API.Abstractions.Objects;
|
||||||
using Remora.Discord.API.Objects;
|
using Remora.Discord.API.Objects;
|
||||||
|
@ -258,4 +259,78 @@ public static class Extensions
|
||||||
|
|
||||||
return (Result)await feedback.SendContextualEmbedAsync(embed, ct: ct);
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
193
src/Services/Update/MemberUpdateService.cs
Normal file
193
src/Services/Update/MemberUpdateService.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
368
src/Services/Update/ScheduledEventUpdateService.cs
Normal file
368
src/Services/Update/ScheduledEventUpdateService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
66
src/Services/Update/SongUpdateService.cs
Normal file
66
src/Services/Update/SongUpdateService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue