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:
parent
19fadead91
commit
793afd0e06
61 changed files with 447 additions and 462 deletions
163
TeamOctolings.Octobot/Services/AccessControlService.cs
Normal file
163
TeamOctolings.Octobot/Services/AccessControlService.cs
Normal 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());
|
||||
}
|
||||
}
|
200
TeamOctolings.Octobot/Services/GuildDataService.cs
Normal file
200
TeamOctolings.Octobot/Services/GuildDataService.cs
Normal 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 _);
|
||||
}
|
||||
}
|
257
TeamOctolings.Octobot/Services/Update/MemberUpdateService.cs
Normal file
257
TeamOctolings.Octobot/Services/Update/MemberUpdateService.cs
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
91
TeamOctolings.Octobot/Services/Update/SongUpdateService.cs
Normal file
91
TeamOctolings.Octobot/Services/Update/SongUpdateService.cs
Normal 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;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue