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

Apply official naming guidelines to Octobot (#306)

1. The root namespace was changed from `Octobot` to
`TeamOctolings.Octobot`:
> DO prefix namespace names with a company name to prevent namespaces
from different companies from having the same name.
2. `Octobot.cs` was renamed to `Program.cs`:
> DO NOT use the same name for a namespace and a type in that namespace.
3. `IOption`, `Option` were renamed to `IGuildOption` and `GuildOption`
respectively:
> DO NOT introduce generic type names such as Element, Node, Log, and
Message.
4. `Utility` was moved out of the `Services` namespace. It didn't belong
there anyway
5. `Program` static fields were moved to `Utility`
6. Localisation files were moved back to the project source files. Looks
like this fixed `Message.Designer.cs` code generation

---------

Signed-off-by: Octol1ttle <l1ttleofficial@outlook.com>
This commit is contained in:
Octol1ttle 2024-05-16 20:34:26 +05:00 committed by GitHub
parent 19fadead91
commit 793afd0e06
Signed by: GitHub
GPG key ID: B5690EEEBB952194
61 changed files with 447 additions and 462 deletions

View file

@ -0,0 +1,163 @@
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Rest.Core;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
namespace TeamOctolings.Octobot.Services;
public sealed class AccessControlService
{
private readonly GuildDataService _data;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly IDiscordRestUserAPI _userApi;
public AccessControlService(GuildDataService data, IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi)
{
_data = data;
_guildApi = guildApi;
_userApi = userApi;
}
private static bool CheckPermission(IEnumerable<IRole> roles, GuildData data, Snowflake memberId,
IGuildMember member,
DiscordPermission permission)
{
var moderatorRole = GuildSettings.ModeratorRole.Get(data.Settings);
if (!moderatorRole.Empty() && data.GetOrCreateMemberData(memberId).Roles.Contains(moderatorRole.Value))
{
return true;
}
return roles
.Where(r => member.Roles.Contains(r.ID))
.Any(r =>
r.Permissions.HasPermission(permission)
);
}
/// <summary>
/// Checks whether or not a member can interact with another member
/// </summary>
/// <param name="guildId">The ID of the guild in which an operation is being performed.</param>
/// <param name="interacterId">The executor of the operation.</param>
/// <param name="targetId">The target of the operation.</param>
/// <param name="action">The operation.</param>
/// <param name="ct">The cancellation token for this operation.</param>
/// <returns>
/// <list type="bullet">
/// <item>A result which has succeeded with a null string if the member can interact with the target.</item>
/// <item>
/// A result which has succeeded with a non-null string containing the error message if the member cannot
/// interact with the target.
/// </item>
/// <item>A result which has failed if an error occurred during the execution of this method.</item>
/// </list>
/// </returns>
public async Task<Result<string?>> CheckInteractionsAsync(
Snowflake guildId, Snowflake? interacterId, Snowflake targetId, string action, CancellationToken ct = default)
{
if (interacterId == targetId)
{
return Result<string?>.FromSuccess($"UserCannot{action}Themselves".Localized());
}
var guildResult = await _guildApi.GetGuildAsync(guildId, ct: ct);
if (!guildResult.IsDefined(out var guild))
{
return Result<string?>.FromError(guildResult);
}
if (interacterId == guild.OwnerID)
{
return Result<string?>.FromSuccess(null);
}
var botResult = await _userApi.GetCurrentUserAsync(ct);
if (!botResult.IsDefined(out var bot))
{
return Result<string?>.FromError(botResult);
}
var botMemberResult = await _guildApi.GetGuildMemberAsync(guildId, bot.ID, ct);
if (!botMemberResult.IsDefined(out var botMember))
{
return Result<string?>.FromError(botMemberResult);
}
var targetMemberResult = await _guildApi.GetGuildMemberAsync(guildId, targetId, ct);
if (!targetMemberResult.IsDefined(out var targetMember))
{
return Result<string?>.FromSuccess(null);
}
var rolesResult = await _guildApi.GetGuildRolesAsync(guildId, ct);
if (!rolesResult.IsDefined(out var roles))
{
return Result<string?>.FromError(rolesResult);
}
if (interacterId is null)
{
return CheckInteractions(action, guild, roles, targetMember, botMember, botMember);
}
var interacterResult = await _guildApi.GetGuildMemberAsync(guildId, interacterId.Value, ct);
if (!interacterResult.IsDefined(out var interacter))
{
return Result<string?>.FromError(interacterResult);
}
var data = await _data.GetData(guildId, ct);
var hasPermission = CheckPermission(roles, data, interacterId.Value, interacter,
action switch
{
"Ban" => DiscordPermission.BanMembers,
"Kick" => DiscordPermission.KickMembers,
"Mute" or "Unmute" => DiscordPermission.ModerateMembers,
_ => throw new Exception()
});
return hasPermission
? CheckInteractions(action, guild, roles, targetMember, botMember, interacter)
: Result<string?>.FromSuccess($"UserCannot{action}Members".Localized());
}
private static Result<string?> CheckInteractions(
string action, IGuild guild, IReadOnlyList<IRole> roles, IGuildMember targetMember, IGuildMember botMember,
IGuildMember interacter)
{
if (!targetMember.User.IsDefined(out var targetUser))
{
return new ArgumentNullError(nameof(targetMember.User));
}
if (botMember.User == targetMember.User)
{
return Result<string?>.FromSuccess($"UserCannot{action}Bot".Localized());
}
if (targetUser.ID == guild.OwnerID)
{
return Result<string?>.FromSuccess($"UserCannot{action}Owner".Localized());
}
var targetRoles = roles.Where(r => targetMember.Roles.Contains(r.ID)).ToList();
var botRoles = roles.Where(r => botMember.Roles.Contains(r.ID));
var targetBotRoleDiff = targetRoles.MaxOrDefault(r => r.Position) - botRoles.MaxOrDefault(r => r.Position);
if (targetBotRoleDiff >= 0)
{
return Result<string?>.FromSuccess($"BotCannot{action}Target".Localized());
}
var interacterRoles = roles.Where(r => interacter.Roles.Contains(r.ID));
var targetInteracterRoleDiff
= targetRoles.MaxOrDefault(r => r.Position) - interacterRoles.MaxOrDefault(r => r.Position);
return targetInteracterRoleDiff < 0
? Result<string?>.FromSuccess(null)
: Result<string?>.FromSuccess($"UserCannot{action}Target".Localized());
}
}

View file

@ -0,0 +1,200 @@
using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Remora.Rest.Core;
using TeamOctolings.Octobot.Data;
namespace TeamOctolings.Octobot.Services;
/// <summary>
/// Handles saving, loading, initializing and providing <see cref="GuildData" />.
/// </summary>
public sealed class GuildDataService : BackgroundService
{
private readonly ConcurrentDictionary<Snowflake, GuildData> _datas = new();
private readonly ILogger<GuildDataService> _logger;
public GuildDataService(ILogger<GuildDataService> logger)
{
_logger = logger;
}
public override Task StopAsync(CancellationToken ct)
{
base.StopAsync(ct);
return SaveAsync(ct);
}
private Task SaveAsync(CancellationToken ct)
{
var tasks = new List<Task>();
var datas = _datas.Values.ToArray();
foreach (var data in datas.Where(data => !data.DataLoadFailed))
{
tasks.Add(SerializeObjectSafelyAsync(data.Settings, data.SettingsPath, ct));
tasks.Add(SerializeObjectSafelyAsync(data.ScheduledEvents, data.ScheduledEventsPath, ct));
var memberDatas = data.MemberData.Values.ToArray();
tasks.AddRange(memberDatas.Select(memberData =>
SerializeObjectSafelyAsync(memberData, $"{data.MemberDataPath}/{memberData.Id}.json", ct)));
}
return Task.WhenAll(tasks);
}
private static async Task SerializeObjectSafelyAsync<T>(T obj, string path, CancellationToken ct)
{
var tempFilePath = path + ".tmp";
await using (var tempFileStream = File.Create(tempFilePath))
{
await JsonSerializer.SerializeAsync(tempFileStream, obj, cancellationToken: ct);
}
File.Copy(tempFilePath, path, true);
File.Delete(tempFilePath);
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
while (await timer.WaitForNextTickAsync(ct))
{
await SaveAsync(ct);
}
}
public async Task<GuildData> GetData(Snowflake guildId, CancellationToken ct = default)
{
return _datas.TryGetValue(guildId, out var data) ? data : await InitializeData(guildId, ct);
}
private async Task<GuildData> InitializeData(Snowflake guildId, CancellationToken ct = default)
{
var path = $"GuildData/{guildId}";
var memberDataPath = $"{path}/MemberData";
var settingsPath = $"{path}/Settings.json";
var scheduledEventsPath = $"{path}/ScheduledEvents.json";
MigrateDataDirectory(guildId, path);
Directory.CreateDirectory(path);
if (!File.Exists(settingsPath))
{
await File.WriteAllTextAsync(settingsPath, "{}", ct);
}
if (!File.Exists(scheduledEventsPath))
{
await File.WriteAllTextAsync(scheduledEventsPath, "{}", ct);
}
var dataLoadFailed = false;
await using var settingsStream = File.OpenRead(settingsPath);
JsonNode? jsonSettings = null;
try
{
jsonSettings = await JsonNode.ParseAsync(settingsStream, cancellationToken: ct);
}
catch (Exception e)
{
_logger.LogError(e, "Guild settings load failed: {Path}", settingsPath);
dataLoadFailed = true;
}
if (jsonSettings is not null)
{
FixJsonSettings(jsonSettings);
}
await using var eventsStream = File.OpenRead(scheduledEventsPath);
Dictionary<ulong, ScheduledEventData>? events = null;
try
{
events = await JsonSerializer.DeserializeAsync<Dictionary<ulong, ScheduledEventData>>(
eventsStream, cancellationToken: ct);
}
catch (Exception e)
{
_logger.LogError(e, "Guild scheduled events load failed: {Path}", scheduledEventsPath);
dataLoadFailed = true;
}
var memberData = new Dictionary<ulong, MemberData>();
foreach (var dataFileInfo in Directory.CreateDirectory(memberDataPath).GetFiles())
{
await using var dataStream = dataFileInfo.OpenRead();
MemberData? data;
try
{
data = await JsonSerializer.DeserializeAsync<MemberData>(dataStream, cancellationToken: ct);
}
catch (Exception e)
{
_logger.LogError(e, "Member data load failed: {MemberDataPath}/{FileName}", memberDataPath,
dataFileInfo.Name);
dataLoadFailed = true;
continue;
}
if (data is null)
{
continue;
}
memberData.Add(data.Id, data);
}
var finalData = new GuildData(
jsonSettings ?? new JsonObject(), settingsPath,
events ?? new Dictionary<ulong, ScheduledEventData>(), scheduledEventsPath,
memberData, memberDataPath,
dataLoadFailed);
_datas.TryAdd(guildId, finalData);
return finalData;
}
private void MigrateDataDirectory(Snowflake guildId, string newPath)
{
var oldPath = $"{guildId}";
if (Directory.Exists(oldPath))
{
Directory.CreateDirectory($"{newPath}/..");
Directory.Move(oldPath, newPath);
_logger.LogInformation("Moved guild data to separate folder: \"{OldPath}\" -> \"{NewPath}\"", oldPath,
newPath);
}
}
private static void FixJsonSettings(JsonNode settings)
{
var language = settings[GuildSettings.Language.Name]?.GetValue<string>();
if (language is "mctaylors-ru")
{
settings[GuildSettings.Language.Name] = "ru";
}
}
public async Task<JsonNode> GetSettings(Snowflake guildId, CancellationToken ct = default)
{
return (await GetData(guildId, ct)).Settings;
}
public ICollection<Snowflake> GetGuildIds()
{
return _datas.Keys;
}
public bool UnloadGuildData(Snowflake id)
{
return _datas.TryRemove(id, out _);
}
}

View file

@ -0,0 +1,257 @@
using System.Text;
using System.Text.RegularExpressions;
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;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
namespace TeamOctolings.Octobot.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 AccessControlService _access;
private readonly IDiscordRestChannelAPI _channelApi;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly GuildDataService _guildData;
private readonly ILogger<MemberUpdateService> _logger;
public MemberUpdateService(AccessControlService access, IDiscordRestChannelAPI channelApi,
IDiscordRestGuildAPI guildApi, GuildDataService guildData, ILogger<MemberUpdateService> logger)
{
_access = access;
_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, $"Error in member data update for guild {id}.");
}));
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>();
var memberDatas = guildData.MemberData.Values.ToArray();
foreach (var data in memberDatas)
{
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();
var autoUnbanResult = await TryAutoUnbanAsync(guildId, id, data, ct);
failedResults.AddIfFailed(autoUnbanResult);
var guildMemberResult = await _guildApi.GetGuildMemberAsync(guildId, id, ct);
if (!guildMemberResult.IsDefined(out var guildMember))
{
return failedResults.AggregateErrors();
}
var interactionResult
= await _access.CheckInteractionsAsync(guildId, null, id, "Update", ct);
if (!interactionResult.IsSuccess)
{
return ResultExtensions.FromError(interactionResult);
}
var canInteract = interactionResult.Entity is null;
if (data.MutedUntil is null)
{
data.Roles = guildMember.Roles.ToList().ConvertAll(r => r.Value);
}
if (!guildMember.User.IsDefined(out var user))
{
failedResults.AddIfFailed(new ArgumentNullError(nameof(guildMember.User)));
return failedResults.AggregateErrors();
}
for (var i = data.Reminders.Count - 1; i >= 0; i--)
{
var reminderTickResult = await TickReminderAsync(data.Reminders[i], user, data, guildId, ct);
failedResults.AddIfFailed(reminderTickResult);
}
if (!canInteract)
{
return Result.Success;
}
var autoUnmuteResult = await TryAutoUnmuteAsync(guildId, id, data, ct);
failedResults.AddIfFailed(autoUnmuteResult);
if (!defaultRole.Empty() && !data.Roles.Contains(defaultRole.Value))
{
var addResult = await _guildApi.AddGuildMemberRoleAsync(
guildId, id, defaultRole, ct: ct);
failedResults.AddIfFailed(addResult);
}
if (GuildSettings.RenameHoistedUsers.Get(guildData.Settings))
{
var filterResult = await FilterNicknameAsync(guildId, user, guildMember, ct);
failedResults.AddIfFailed(filterResult);
}
return failedResults.AggregateErrors();
}
private async Task<Result> TryAutoUnbanAsync(
Snowflake guildId, Snowflake id, MemberData data, CancellationToken ct)
{
if (data.BannedUntil is null || DateTimeOffset.UtcNow <= data.BannedUntil)
{
return Result.Success;
}
var existingBanResult = await _guildApi.GetGuildBanAsync(guildId, id, ct);
if (!existingBanResult.IsDefined())
{
data.BannedUntil = null;
return Result.Success;
}
var unbanResult = await _guildApi.RemoveGuildBanAsync(
guildId, id, Messages.PunishmentExpired.EncodeHeader(), ct);
if (unbanResult.IsSuccess)
{
data.BannedUntil = null;
}
return unbanResult;
}
private async Task<Result> TryAutoUnmuteAsync(
Snowflake guildId, Snowflake id, MemberData data, CancellationToken ct)
{
if (data.MutedUntil is null || DateTimeOffset.UtcNow <= data.MutedUntil)
{
return Result.Success;
}
var unmuteResult = await _guildApi.ModifyGuildMemberAsync(
guildId, id, roles: data.Roles.ConvertAll(r => r.ToSnowflake()),
reason: Messages.PunishmentExpired.EncodeHeader(), ct: ct);
if (unmuteResult.IsSuccess)
{
data.MutedUntil = null;
}
return unmuteResult;
}
private async Task<Result> FilterNicknameAsync(Snowflake guildId, IUser user, IGuildMember member,
CancellationToken ct)
{
var currentNickname = member.Nickname.IsDefined(out var nickname)
? nickname
: user.GlobalName.OrDefault(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.Success;
}
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-Za-zА-Яа-яЁё]")]
private static partial Regex IllegalChars();
private async Task<Result> TickReminderAsync(Reminder reminder, IUser user, MemberData data, Snowflake guildId,
CancellationToken ct)
{
if (DateTimeOffset.UtcNow < reminder.At)
{
return Result.Success;
}
var builder = new StringBuilder()
.AppendBulletPointLine(string.Format(Messages.DescriptionReminder, Markdown.InlineCode(reminder.Text)))
.AppendBulletPointLine(string.Format(Messages.DescriptionActionJumpToMessage,
$"https://discord.com/channels/{guildId.Value}/{reminder.ChannelId}/{reminder.MessageId}"));
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.Reminder, user.GetTag()), user)
.WithDescription(builder.ToString())
.WithColour(ColorsList.Magenta)
.Build();
var messageResult = await _channelApi.CreateMessageWithEmbedResultAsync(
reminder.ChannelId.ToSnowflake(), Mention.User(user), embedResult: embed, ct: ct);
if (!messageResult.IsSuccess)
{
return ResultExtensions.FromError(messageResult);
}
data.Reminders.Remove(reminder);
return Result.Success;
}
}

View file

@ -0,0 +1,434 @@
using System.Text.Json.Nodes;
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.Rest.Core;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
namespace TeamOctolings.Octobot.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 Utility _utility;
public ScheduledEventUpdateService(IDiscordRestChannelAPI channelApi, IDiscordRestGuildScheduledEventAPI eventApi,
GuildDataService guildData, ILogger<ScheduledEventUpdateService> logger, Utility utility)
{
_channelApi = channelApi;
_eventApi = eventApi;
_guildData = guildData;
_logger = logger;
_utility = utility;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
while (await timer.WaitForNextTickAsync(ct))
{
var guildIds = _guildData.GetGuildIds();
foreach (var id in guildIds)
{
var tickResult = await TickScheduledEventsAsync(id, ct);
_logger.LogResult(tickResult, $"Error in scheduled events update for guild {id}.");
}
}
}
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 ResultExtensions.FromError(eventsResult);
}
SyncScheduledEvents(data, events);
foreach (var storedEvent in data.ScheduledEvents.Values)
{
var scheduledEvent = TryGetScheduledEvent(events, storedEvent.Id);
if (!scheduledEvent.IsSuccess)
{
storedEvent.ScheduleOnStatusUpdated = true;
storedEvent.Status = storedEvent.ActualStartTime != null
? GuildScheduledEventStatus.Completed
: GuildScheduledEventStatus.Canceled;
}
if (!storedEvent.ScheduleOnStatusUpdated)
{
var tickResult =
await TickScheduledEventAsync(guildId, data, scheduledEvent.Entity, storedEvent, ct);
failedResults.AddIfFailed(tickResult);
continue;
}
var statusUpdatedResponseResult = storedEvent.Status switch
{
GuildScheduledEventStatus.Scheduled =>
await SendScheduledEventCreatedMessage(scheduledEvent.Entity, data.Settings, ct),
GuildScheduledEventStatus.Canceled =>
await SendScheduledEventCancelledMessage(storedEvent, data, ct),
GuildScheduledEventStatus.Active =>
await SendScheduledEventStartedMessage(scheduledEvent.Entity, data, ct),
GuildScheduledEventStatus.Completed =>
await SendScheduledEventCompletedMessage(storedEvent, data, ct),
_ => new ArgumentOutOfRangeError(nameof(storedEvent.Status))
};
if (statusUpdatedResponseResult.IsSuccess)
{
storedEvent.ScheduleOnStatusUpdated = false;
}
failedResults.AddIfFailed(statusUpdatedResponseResult);
}
return failedResults.AggregateErrors();
}
private static void SyncScheduledEvents(GuildData data, IEnumerable<IGuildScheduledEvent> events)
{
foreach (var @event in events)
{
if (!data.ScheduledEvents.TryGetValue(@event.ID.Value, out var eventData))
{
data.ScheduledEvents.Add(@event.ID.Value,
new ScheduledEventData(@event.ID.Value, @event.Name, @event.ScheduledStartTime, @event.Status));
continue;
}
eventData.Name = @event.Name;
eventData.ScheduledStartTime = @event.ScheduledStartTime;
if (!eventData.ScheduleOnStatusUpdated)
{
eventData.ScheduleOnStatusUpdated = eventData.Status != @event.Status;
}
eventData.Status = @event.Status;
}
}
private static Result<IGuildScheduledEvent> TryGetScheduledEvent(IEnumerable<IGuildScheduledEvent> from, ulong id)
{
var filtered = from.Where(schEvent => schEvent.ID == id);
var filteredArray = filtered.ToArray();
return filteredArray.Length > 0
? Result<IGuildScheduledEvent>.FromSuccess(filteredArray.Single())
: new NotFoundError();
}
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.Success;
}
var sendResult = await SendEarlyEventNotificationAsync(scheduledEvent, data, ct);
if (sendResult.IsSuccess)
{
eventData.EarlyNotificationSent = true;
}
return sendResult;
}
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 (GuildSettings.EventNotificationChannel.Get(settings).Empty())
{
return Result.Success;
}
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 ResultExtensions.FromError(embedDescriptionResult);
}
var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(Messages.EventCreatedTitle, creator.GetTag()), creator)
.WithTitle(Markdown.Sanitize(scheduledEvent.Name))
.WithDescription(embedDescription)
.WithEventCover(scheduledEvent.ID, scheduledEvent.Image)
.WithCurrentTimestamp()
.WithColour(ColorsList.White)
.Build();
var roleMention = !GuildSettings.EventNotificationRole.Get(settings).Empty()
? Mention.Role(GuildSettings.EventNotificationRole.Get(settings))
: string.Empty;
var button = new ButtonComponent(
ButtonComponentStyle.Link,
Messages.ButtonOpenEventInfo,
new PartialEmoji(Name: "\ud83d\udccb"), // 'CLIPBOARD' (U+1F4CB)
URL: $"https://discord.com/events/{scheduledEvent.GuildID}/{scheduledEvent.ID}"
);
return await _channelApi.CreateMessageWithEmbedResultAsync(
GuildSettings.EventNotificationChannel.Get(settings), roleMention, embedResult: embed,
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> SendScheduledEventStartedMessage(
IGuildScheduledEvent scheduledEvent, GuildData data, CancellationToken ct = default)
{
data.ScheduledEvents[scheduledEvent.ID.Value].ActualStartTime = DateTimeOffset.UtcNow;
if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty())
{
return Result.Success;
}
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, ct);
if (!contentResult.IsDefined(out var content))
{
return ResultExtensions.FromError(contentResult);
}
if (!embedDescriptionResult.IsDefined(out var embedDescription))
{
return ResultExtensions.FromError(embedDescriptionResult);
}
var startedEmbed = new EmbedBuilder()
.WithTitle(string.Format(Messages.EventStarted, Markdown.Sanitize(scheduledEvent.Name)))
.WithDescription(embedDescription)
.WithColour(ColorsList.Green)
.WithCurrentTimestamp()
.Build();
return await _channelApi.CreateMessageWithEmbedResultAsync(
GuildSettings.EventNotificationChannel.Get(data.Settings),
content, embedResult: startedEmbed, ct: ct);
}
private async Task<Result> SendScheduledEventCompletedMessage(ScheduledEventData eventData, GuildData data,
CancellationToken ct)
{
if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty())
{
data.ScheduledEvents.Remove(eventData.Id);
return Result.Success;
}
var completedEmbed = new EmbedBuilder()
.WithTitle(string.Format(Messages.EventCompleted, Markdown.Sanitize(eventData.Name)))
.WithDescription(
string.Format(
Messages.EventDuration,
DateTimeOffset.UtcNow.Subtract(
eventData.ActualStartTime
?? eventData.ScheduledStartTime).ToString()))
.WithColour(ColorsList.Black)
.WithCurrentTimestamp()
.Build();
var createResult = await _channelApi.CreateMessageWithEmbedResultAsync(
GuildSettings.EventNotificationChannel.Get(data.Settings),
embedResult: completedEmbed, ct: ct);
if (createResult.IsSuccess)
{
data.ScheduledEvents.Remove(eventData.Id);
}
return createResult;
}
private async Task<Result> SendScheduledEventCancelledMessage(ScheduledEventData eventData, GuildData data,
CancellationToken ct)
{
if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty())
{
data.ScheduledEvents.Remove(eventData.Id);
return Result.Success;
}
var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(Messages.EventCancelled, Markdown.Sanitize(eventData.Name)))
.WithDescription(":(")
.WithColour(ColorsList.Red)
.WithCurrentTimestamp()
.Build();
var createResult = await _channelApi.CreateMessageWithEmbedResultAsync(
GuildSettings.EventNotificationChannel.Get(data.Settings), embedResult: embed, ct: ct);
if (createResult.IsSuccess)
{
data.ScheduledEvents.Remove(eventData.Id);
}
return createResult;
}
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)
{
if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty())
{
return Result.Success;
}
var contentResult = await _utility.GetEventNotificationMentions(
scheduledEvent, data, ct);
if (!contentResult.IsDefined(out var content))
{
return ResultExtensions.FromError(contentResult);
}
var earlyResult = new EmbedBuilder()
.WithDescription(
string.Format(Messages.EventEarlyNotification, Markdown.Sanitize(scheduledEvent.Name),
Markdown.Timestamp(scheduledEvent.ScheduledStartTime, TimestampStyle.RelativeTime)))
.WithColour(ColorsList.Default)
.Build();
return await _channelApi.CreateMessageWithEmbedResultAsync(
GuildSettings.EventNotificationChannel.Get(data.Settings),
content,
embedResult: earlyResult, ct: ct);
}
}

View file

@ -0,0 +1,91 @@
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 TeamOctolings.Octobot.Services.Update;
public sealed class SongUpdateService : BackgroundService
{
private static readonly (string Author, string Name, TimeSpan Duration)[] SongList =
[
("Yoko & the Gold Bazookas", "Rockagilly Blues", new TimeSpan(0, 2, 52)),
("Deep Cut", "Big Betrayal", new TimeSpan(0, 5, 55)),
("Squid Sisters", "Tomorrow's Nostalgia Today", new TimeSpan(0, 3, 7)),
("Deep Cut", "Anarchy Rainbow", new TimeSpan(0, 3, 20)),
("Squid Sisters feat. Ian BGM", "Liquid Sunshine", new TimeSpan(0, 2, 37)),
("Damp Socks feat. Off the Hook", "Candy-Coated Rocks", new TimeSpan(0, 2, 58)),
("H2Whoa", "Aquasonic", new TimeSpan(0, 2, 51)),
("Yoko & the Gold Bazookas", "Ska-BLAM", new TimeSpan(0, 2, 57)),
("Off the Hook", "Muck Warfare", new TimeSpan(0, 3, 20)),
("Off the Hook", "Acid Hues", new TimeSpan(0, 3, 15)),
("Off the Hook", "Shark Bytes", new TimeSpan(0, 3, 34)),
("Squid Sisters", "Calamari Inkantation", new TimeSpan(0, 2, 14)),
("Squid Sisters", "Ink Me Up", new TimeSpan(0, 2, 13)),
("Chirpy Chips", "No Quarters", new TimeSpan(0, 2, 36)),
("Chirpy Chips", "Shellfie", new TimeSpan(0, 2, 1)),
("Dedf1sh", "#11 above", new TimeSpan(0, 2, 10)),
("Callie", "Bomb Rush Blush", new TimeSpan(0, 2, 18)),
("Turquoise October", "Octoling Rendezvous", new TimeSpan(0, 1, 57)),
("Damp Socks feat. Off the Hook", "Tentacle to the Metal", new TimeSpan(0, 2, 51)),
("Off the Hook", "Fly Octo Fly ~ Ebb & Flow (Octo)", new TimeSpan(0, 3, 5))
];
private static readonly (string Author, string Name, TimeSpan Duration)[] SpecialSongList =
[
("Squid Sisters", "Maritime Memory", new TimeSpan(0, 2, 47))
];
private readonly List<Activity> _activityList = [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 = NextSong();
_activityList[0] = new Activity($"{nextSong.Name} / {nextSong.Author}",
ActivityType.Listening);
_client.SubmitCommand(
new UpdatePresence(
UserStatus.Online, false, DateTimeOffset.UtcNow, _activityList));
await Task.Delay(nextSong.Duration, ct);
}
}
private (string Author, string Name, TimeSpan Duration) NextSong()
{
var today = DateTime.Today;
// Discontinuation of Online Services for Nintendo Wii U
if (today.Day is 8 or 9 && today.Month is 4)
{
return SpecialSongList[0]; // Maritime Memory / Squid Sisters
}
var nextSong = SongList[_nextSongIndex];
_nextSongIndex++;
if (_nextSongIndex >= SongList.Length)
{
_nextSongIndex = 0;
}
return nextSong;
}
}