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