diff --git a/Boyfriend.csproj b/Boyfriend.csproj
index f6cc7dc..b7d095b 100644
--- a/Boyfriend.csproj
+++ b/Boyfriend.csproj
@@ -21,6 +21,7 @@
+
diff --git a/src/Boyfriend.cs b/src/Boyfriend.cs
index d285213..8045a60 100644
--- a/src/Boyfriend.cs
+++ b/src/Boyfriend.cs
@@ -64,15 +64,19 @@ public class Boyfriend {
});
services.AddTransient()
+ // Init
.AddDiscordCaching()
.AddDiscordCommands(true)
+ .AddInteractivity()
+ // Slash command event handlers
.AddPreparationErrorEvent()
.AddPostExecutionEvent()
- .AddInteractivity()
.AddInteractionGroup()
+ // Services
.AddSingleton()
.AddSingleton()
.AddHostedService()
+ // Slash commands
.AddCommandTree()
.WithCommandGroup()
.WithCommandGroup()
diff --git a/src/Commands/AboutCommandGroup.cs b/src/Commands/AboutCommandGroup.cs
index 32ce07d..af65b15 100644
--- a/src/Commands/AboutCommandGroup.cs
+++ b/src/Commands/AboutCommandGroup.cs
@@ -1,7 +1,6 @@
using System.ComponentModel;
using System.Text;
using Boyfriend.Data;
-using Boyfriend.locale;
using Boyfriend.Services;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
@@ -12,9 +11,6 @@ using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Results;
-// ReSharper disable ClassNeverInstantiated.Global
-// ReSharper disable UnusedMember.Global
-
namespace Boyfriend.Commands;
///
diff --git a/src/Commands/BanCommandGroup.cs b/src/Commands/BanCommandGroup.cs
index b209683..bd2439e 100644
--- a/src/Commands/BanCommandGroup.cs
+++ b/src/Commands/BanCommandGroup.cs
@@ -1,7 +1,6 @@
using System.ComponentModel;
using System.Text;
using Boyfriend.Data;
-using Boyfriend.locale;
using Boyfriend.Services;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
@@ -15,9 +14,6 @@ using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Results;
-// ReSharper disable ClassNeverInstantiated.Global
-// ReSharper disable UnusedMember.Global
-
namespace Boyfriend.Commands;
///
diff --git a/src/Commands/ClearCommandGroup.cs b/src/Commands/ClearCommandGroup.cs
index dd396c3..21d6119 100644
--- a/src/Commands/ClearCommandGroup.cs
+++ b/src/Commands/ClearCommandGroup.cs
@@ -1,8 +1,8 @@
using System.ComponentModel;
using System.Text;
using Boyfriend.Data;
-using Boyfriend.locale;
using Boyfriend.Services;
+using JetBrains.Annotations;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
@@ -16,14 +16,12 @@ using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core;
using Remora.Results;
-// ReSharper disable ClassNeverInstantiated.Global
-// ReSharper disable UnusedMember.Global
-
namespace Boyfriend.Commands;
///
/// Handles the command to clear messages in a channel: /clear.
///
+[UsedImplicitly]
public class ClearCommandGroup : CommandGroup {
private readonly IDiscordRestChannelAPI _channelApi;
private readonly ICommandContext _context;
@@ -54,6 +52,7 @@ public class ClearCommandGroup : CommandGroup {
[RequireDiscordPermission(DiscordPermission.ManageMessages)]
[RequireBotDiscordPermissions(DiscordPermission.ManageMessages)]
[Description("Remove multiple messages")]
+ [UsedImplicitly]
public async Task ClearMessagesAsync(
[Description("Number of messages to remove (2-100)")] [MinValue(2)] [MaxValue(100)]
int amount) {
diff --git a/src/Commands/ErrorLoggingEvents.cs b/src/Commands/ErrorLoggingEvents.cs
index 30869b4..e35493e 100644
--- a/src/Commands/ErrorLoggingEvents.cs
+++ b/src/Commands/ErrorLoggingEvents.cs
@@ -3,8 +3,6 @@ using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Services;
using Remora.Results;
-// ReSharper disable ClassNeverInstantiated.Global
-
namespace Boyfriend.Commands;
///
diff --git a/src/Commands/KickCommandGroup.cs b/src/Commands/KickCommandGroup.cs
index a095c47..eb06b50 100644
--- a/src/Commands/KickCommandGroup.cs
+++ b/src/Commands/KickCommandGroup.cs
@@ -1,6 +1,5 @@
using System.ComponentModel;
using Boyfriend.Data;
-using Boyfriend.locale;
using Boyfriend.Services;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
@@ -13,9 +12,6 @@ using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Results;
-// ReSharper disable ClassNeverInstantiated.Global
-// ReSharper disable UnusedMember.Global
-
namespace Boyfriend.Commands;
///
diff --git a/src/Commands/MuteCommandGroup.cs b/src/Commands/MuteCommandGroup.cs
index 791519d..76a44d2 100644
--- a/src/Commands/MuteCommandGroup.cs
+++ b/src/Commands/MuteCommandGroup.cs
@@ -1,7 +1,6 @@
using System.ComponentModel;
using System.Text;
using Boyfriend.Data;
-using Boyfriend.locale;
using Boyfriend.Services;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
@@ -15,9 +14,6 @@ using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Results;
-// ReSharper disable ClassNeverInstantiated.Global
-// ReSharper disable UnusedMember.Global
-
namespace Boyfriend.Commands;
///
diff --git a/src/Commands/PingCommandGroup.cs b/src/Commands/PingCommandGroup.cs
index f25b435..9f336ca 100644
--- a/src/Commands/PingCommandGroup.cs
+++ b/src/Commands/PingCommandGroup.cs
@@ -1,6 +1,5 @@
using System.ComponentModel;
using Boyfriend.Data;
-using Boyfriend.locale;
using Boyfriend.Services;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
@@ -11,9 +10,6 @@ using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway;
using Remora.Results;
-// ReSharper disable ClassNeverInstantiated.Global
-// ReSharper disable UnusedMember.Global
-
namespace Boyfriend.Commands;
///
diff --git a/src/Commands/RemindCommandGroup.cs b/src/Commands/RemindCommandGroup.cs
index 8305ef5..430c1cd 100644
--- a/src/Commands/RemindCommandGroup.cs
+++ b/src/Commands/RemindCommandGroup.cs
@@ -1,6 +1,5 @@
using System.ComponentModel;
using Boyfriend.Data;
-using Boyfriend.locale;
using Boyfriend.Services;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
@@ -11,9 +10,6 @@ using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Results;
-// ReSharper disable ClassNeverInstantiated.Global
-// ReSharper disable UnusedMember.Global
-
namespace Boyfriend.Commands;
///
diff --git a/src/Commands/SettingsCommandGroup.cs b/src/Commands/SettingsCommandGroup.cs
index 61c5786..6b6c594 100644
--- a/src/Commands/SettingsCommandGroup.cs
+++ b/src/Commands/SettingsCommandGroup.cs
@@ -2,7 +2,6 @@
using System.Text;
using Boyfriend.Data;
using Boyfriend.Data.Options;
-using Boyfriend.locale;
using Boyfriend.Services;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
@@ -13,9 +12,6 @@ using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Results;
-// ReSharper disable ClassNeverInstantiated.Global
-// ReSharper disable UnusedMember.Global
-
namespace Boyfriend.Commands;
///
@@ -77,8 +73,7 @@ public class SettingsCommandGroup : CommandGroup {
foreach (var option in AllOptions) {
builder.Append(Markdown.InlineCode(option.Name))
.Append(": ");
- var something = option.GetAsObject(cfg);
- builder.AppendLine(Markdown.InlineCode(something.ToString()!));
+ builder.AppendLine(option.Display(cfg));
}
var embed = new EmbedBuilder().WithSmallTitle(Messages.SettingsListTitle, currentUser)
@@ -131,7 +126,7 @@ public class SettingsCommandGroup : CommandGroup {
builder.Append(Markdown.InlineCode(option.Name))
.Append($" {Messages.SettingIsNow} ")
- .Append(Markdown.InlineCode(option.GetAsObject(cfg).ToString()!));
+ .Append(Markdown.InlineCode(option.Display(cfg)));
var embed = new EmbedBuilder().WithSmallTitle(Messages.SettingSuccessfullyChanged, currentUser)
.WithDescription(builder.ToString())
diff --git a/src/Data/GuildSettings.cs b/src/Data/GuildSettings.cs
index fc76f21..4e5e6cc 100644
--- a/src/Data/GuildSettings.cs
+++ b/src/Data/GuildSettings.cs
@@ -1,5 +1,4 @@
using Boyfriend.Data.Options;
-using Boyfriend.locale;
using Remora.Discord.API.Abstractions.Objects;
namespace Boyfriend.Data;
diff --git a/src/Data/Options/BoolOption.cs b/src/Data/Options/BoolOption.cs
index 0a3992c..a8ee954 100644
--- a/src/Data/Options/BoolOption.cs
+++ b/src/Data/Options/BoolOption.cs
@@ -1,5 +1,4 @@
using System.Text.Json.Nodes;
-using Boyfriend.locale;
using Remora.Results;
namespace Boyfriend.Data.Options;
@@ -7,6 +6,10 @@ namespace Boyfriend.Data.Options;
public class BoolOption : Option {
public BoolOption(string name, bool defaultValue) : base(name, defaultValue) { }
+ public override string Display(JsonNode settings) {
+ return Get(settings) ? Messages.Yes : Messages.No;
+ }
+
public override Result Set(JsonNode settings, string from) {
if (!TryParseBool(from, out var value))
return Result.FromError(new ArgumentInvalidError(nameof(from), Messages.InvalidSettingValue));
diff --git a/src/Data/Options/IOption.cs b/src/Data/Options/IOption.cs
index 211c5b2..fc0f747 100644
--- a/src/Data/Options/IOption.cs
+++ b/src/Data/Options/IOption.cs
@@ -5,6 +5,6 @@ namespace Boyfriend.Data.Options;
public interface IOption {
string Name { get; }
- object GetAsObject(JsonNode settings);
- Result Set(JsonNode settings, string from);
+ string Display(JsonNode settings);
+ Result Set(JsonNode settings, string from);
}
diff --git a/src/Data/Options/LanguageOption.cs b/src/Data/Options/LanguageOption.cs
index 7e741b2..6c4a49f 100644
--- a/src/Data/Options/LanguageOption.cs
+++ b/src/Data/Options/LanguageOption.cs
@@ -1,6 +1,6 @@
using System.Globalization;
using System.Text.Json.Nodes;
-using Boyfriend.locale;
+using Remora.Discord.Extensions.Formatting;
using Remora.Results;
namespace Boyfriend.Data.Options;
@@ -15,6 +15,10 @@ public class LanguageOption : Option {
public LanguageOption(string name, string defaultValue) : base(name, CultureInfoCache[defaultValue]) { }
+ public override string Display(JsonNode settings) {
+ return Markdown.InlineCode(settings[Name]?.GetValue() ?? "en");
+ }
+
///
public override CultureInfo Get(JsonNode settings) {
var property = settings[Name];
diff --git a/src/Data/Options/Option.cs b/src/Data/Options/Option.cs
index 77eace9..742d3a9 100644
--- a/src/Data/Options/Option.cs
+++ b/src/Data/Options/Option.cs
@@ -1,4 +1,5 @@
using System.Text.Json.Nodes;
+using Remora.Discord.Extensions.Formatting;
using Remora.Results;
namespace Boyfriend.Data.Options;
@@ -7,7 +8,8 @@ namespace Boyfriend.Data.Options;
/// Represents an per-guild option.
///
/// The type of the option.
-public class Option : IOption {
+public class Option : IOption
+where T : notnull {
internal readonly T DefaultValue;
public Option(string name, T defaultValue) {
@@ -17,8 +19,8 @@ public class Option : IOption {
public string Name { get; }
- public object GetAsObject(JsonNode settings) {
- return Get(settings)!;
+ public virtual string Display(JsonNode settings) {
+ return Markdown.InlineCode(Get(settings).ToString()!);
}
///
diff --git a/src/Data/Options/SnowflakeOption.cs b/src/Data/Options/SnowflakeOption.cs
index d50f833..f65065c 100644
--- a/src/Data/Options/SnowflakeOption.cs
+++ b/src/Data/Options/SnowflakeOption.cs
@@ -1,5 +1,5 @@
using System.Text.Json.Nodes;
-using Boyfriend.locale;
+using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core;
using Remora.Results;
@@ -8,6 +8,10 @@ namespace Boyfriend.Data.Options;
public class SnowflakeOption : Option {
public SnowflakeOption(string name) : base(name, 0UL.ToSnowflake()) { }
+ public override string Display(JsonNode settings) {
+ return Name.EndsWith("Channel") ? Mention.Channel(Get(settings)) : Mention.Role(Get(settings));
+ }
+
public override Snowflake Get(JsonNode settings) {
var property = settings[Name];
return property != null ? property.GetValue().ToSnowflake() : DefaultValue;
diff --git a/src/Data/Options/TimeSpanOption.cs b/src/Data/Options/TimeSpanOption.cs
index 68b33ad..4be2439 100644
--- a/src/Data/Options/TimeSpanOption.cs
+++ b/src/Data/Options/TimeSpanOption.cs
@@ -1,5 +1,4 @@
using System.Text.Json.Nodes;
-using Boyfriend.locale;
using Remora.Commands.Parsers;
using Remora.Results;
diff --git a/src/EventResponders.cs b/src/EventResponders.cs
deleted file mode 100644
index 0b5b309..0000000
--- a/src/EventResponders.cs
+++ /dev/null
@@ -1,339 +0,0 @@
-using Boyfriend.Data;
-using Boyfriend.locale;
-using Boyfriend.Services;
-using DiffPlex.DiffBuilder;
-using Microsoft.Extensions.Logging;
-using Remora.Discord.API.Abstractions.Gateway.Events;
-using Remora.Discord.API.Abstractions.Objects;
-using Remora.Discord.API.Abstractions.Rest;
-using Remora.Discord.Caching;
-using Remora.Discord.Caching.Services;
-using Remora.Discord.Extensions.Embeds;
-using Remora.Discord.Extensions.Formatting;
-using Remora.Discord.Gateway.Responders;
-using Remora.Rest.Core;
-using Remora.Results;
-
-// ReSharper disable UnusedType.Global
-
-namespace Boyfriend;
-
-///
-/// Handles sending a message to a guild that has just initialized if that guild
-/// has enabled
-///
-public class GuildCreateResponder : IResponder {
- private readonly IDiscordRestChannelAPI _channelApi;
- private readonly GuildDataService _dataService;
- private readonly ILogger _logger;
- private readonly IDiscordRestUserAPI _userApi;
-
- public GuildCreateResponder(
- IDiscordRestChannelAPI channelApi, GuildDataService dataService, ILogger logger,
- IDiscordRestUserAPI userApi) {
- _channelApi = channelApi;
- _dataService = dataService;
- _logger = logger;
- _userApi = userApi;
- }
-
- public async Task RespondAsync(IGuildCreate gatewayEvent, CancellationToken ct = default) {
- if (!gatewayEvent.Guild.IsT0) return Result.FromSuccess(); // Guild isn't IAvailableGuild
-
- var guild = gatewayEvent.Guild.AsT0;
- _logger.LogInformation("Joined guild \"{Name}\"", guild.Name);
-
- var cfg = await _dataService.GetSettings(guild.ID, ct);
- if (!GuildSettings.ReceiveStartupMessages.Get(cfg))
- return Result.FromSuccess();
- if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty())
- return Result.FromSuccess();
-
- var currentUserResult = await _userApi.GetCurrentUserAsync(ct);
- if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult);
-
- Messages.Culture = GuildSettings.Language.Get(cfg);
- var i = Random.Shared.Next(1, 4);
-
- var embed = new EmbedBuilder().WithSmallTitle(currentUser.GetTag(), currentUser)
- .WithTitle($"Beep{i}".Localized())
- .WithDescription(Messages.Ready)
- .WithCurrentTimestamp()
- .WithColour(ColorsList.Blue)
- .Build();
- if (!embed.IsDefined(out var built)) return Result.FromError(embed);
-
- return (Result)await _channelApi.CreateMessageAsync(
- GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: new[] { built }, ct: ct);
- }
-}
-
-///
-/// Handles logging the contents of a deleted message and the user who deleted the message
-/// to a guild's if one is set.
-///
-public class MessageDeletedResponder : IResponder {
- private readonly IDiscordRestAuditLogAPI _auditLogApi;
- private readonly IDiscordRestChannelAPI _channelApi;
- private readonly GuildDataService _dataService;
- private readonly IDiscordRestUserAPI _userApi;
-
- public MessageDeletedResponder(
- IDiscordRestAuditLogAPI auditLogApi, IDiscordRestChannelAPI channelApi,
- GuildDataService dataService, IDiscordRestUserAPI userApi) {
- _auditLogApi = auditLogApi;
- _channelApi = channelApi;
- _dataService = dataService;
- _userApi = userApi;
- }
-
- public async Task RespondAsync(IMessageDelete gatewayEvent, CancellationToken ct = default) {
- if (!gatewayEvent.GuildID.IsDefined(out var guildId)) return Result.FromSuccess();
-
- var cfg = await _dataService.GetSettings(guildId, ct);
- if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()) return Result.FromSuccess();
-
- var messageResult = await _channelApi.GetChannelMessageAsync(gatewayEvent.ChannelID, gatewayEvent.ID, ct);
- if (!messageResult.IsDefined(out var message)) return Result.FromError(messageResult);
- if (string.IsNullOrWhiteSpace(message.Content)) return Result.FromSuccess();
-
- var auditLogResult = await _auditLogApi.GetGuildAuditLogAsync(
- guildId, actionType: AuditLogEvent.MessageDelete, limit: 1, ct: ct);
- if (!auditLogResult.IsDefined(out var auditLogPage)) return Result.FromError(auditLogResult);
-
- var auditLog = auditLogPage.AuditLogEntries.Single();
- if (!auditLog.Options.IsDefined(out var options))
- return Result.FromError(new ArgumentNullError(nameof(auditLog.Options)));
-
- var user = message.Author;
- if (options.ChannelID == gatewayEvent.ChannelID
- && DateTimeOffset.UtcNow.Subtract(auditLog.ID.Timestamp).TotalSeconds <= 2) {
- var userResult = await _userApi.GetUserAsync(auditLog.UserID!.Value, ct);
- if (!userResult.IsDefined(out user)) return Result.FromError(userResult);
- }
-
- Messages.Culture = GuildSettings.Language.Get(cfg);
-
- var embed = new EmbedBuilder()
- .WithSmallTitle(
- string.Format(
- Messages.CachedMessageDeleted,
- message.Author.GetTag()), message.Author)
- .WithDescription(
- $"{Mention.Channel(gatewayEvent.ChannelID)}\n{message.Content.InBlockCode()}")
- .WithActionFooter(user)
- .WithTimestamp(message.Timestamp)
- .WithColour(ColorsList.Red)
- .Build();
- if (!embed.IsDefined(out var built)) return Result.FromError(embed);
-
- return (Result)await _channelApi.CreateMessageAsync(
- GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: new[] { built },
- allowedMentions: Boyfriend.NoMentions, ct: ct);
- }
-}
-
-///
-/// Handles logging the difference between an edited message's old and new content
-/// to a guild's if one is set.
-///
-public class MessageEditedResponder : IResponder {
- private readonly CacheService _cacheService;
- private readonly IDiscordRestChannelAPI _channelApi;
- private readonly GuildDataService _dataService;
- private readonly IDiscordRestUserAPI _userApi;
-
- public MessageEditedResponder(
- CacheService cacheService, IDiscordRestChannelAPI channelApi, GuildDataService dataService,
- IDiscordRestUserAPI userApi) {
- _cacheService = cacheService;
- _channelApi = channelApi;
- _dataService = dataService;
- _userApi = userApi;
- }
-
- public async Task RespondAsync(IMessageUpdate gatewayEvent, CancellationToken ct = default) {
- if (!gatewayEvent.GuildID.IsDefined(out var guildId))
- return Result.FromSuccess();
- var cfg = await _dataService.GetSettings(guildId, ct);
- if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty())
- return Result.FromSuccess();
- if (!gatewayEvent.Content.IsDefined(out var newContent))
- return Result.FromSuccess();
- if (!gatewayEvent.EditedTimestamp.IsDefined(out var timestamp))
- return Result.FromSuccess(); // The message wasn't actually edited
-
- if (!gatewayEvent.ChannelID.IsDefined(out var channelId))
- return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.ChannelID)));
- if (!gatewayEvent.ID.IsDefined(out var messageId))
- return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.ID)));
-
- var cacheKey = new KeyHelpers.MessageCacheKey(channelId, messageId);
- var messageResult = await _cacheService.TryGetValueAsync(
- cacheKey, ct);
- if (!messageResult.IsDefined(out var message)) return Result.FromError(messageResult);
- if (message.Content == newContent) return Result.FromSuccess();
-
- // Custom event responders are called earlier than responders responsible for message caching
- // This means that subsequent edit logs may contain the wrong content
- // We can work around this by evicting the message from the cache
- await _cacheService.EvictAsync(cacheKey, ct);
- // However, since we evicted the message, subsequent edits won't have a cached instance to work with
- // Getting the message will put it back in the cache, resolving all issues
- // We don't need to await this since the result is not needed
- // NOTE: Because this is not awaited, there may be a race condition depending on how fast clients are able to edit their messages
- // NOTE: Awaiting this might not even solve this if the same responder is called asynchronously
- _ = _channelApi.GetChannelMessageAsync(channelId, messageId, ct);
-
- var currentUserResult = await _userApi.GetCurrentUserAsync(ct);
- if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult);
-
- var diff = InlineDiffBuilder.Diff(message.Content, newContent);
-
- Messages.Culture = GuildSettings.Language.Get(cfg);
-
- var embed = new EmbedBuilder()
- .WithSmallTitle(string.Format(Messages.CachedMessageEdited, message.Author.GetTag()), message.Author)
- .WithDescription($"https://discord.com/channels/{guildId}/{channelId}/{messageId}\n{diff.AsMarkdown()}")
- .WithUserFooter(currentUser)
- .WithTimestamp(timestamp.Value)
- .WithColour(ColorsList.Yellow)
- .Build();
- if (!embed.IsDefined(out var built)) return Result.FromError(embed);
-
- return (Result)await _channelApi.CreateMessageAsync(
- GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: new[] { built },
- allowedMentions: Boyfriend.NoMentions, ct: ct);
- }
-}
-
-///
-/// Handles sending a guild's if one is set.
-/// If is enabled, roles will be returned.
-///
-///
-public class GuildMemberAddResponder : IResponder {
- private readonly IDiscordRestChannelAPI _channelApi;
- private readonly GuildDataService _dataService;
- private readonly IDiscordRestGuildAPI _guildApi;
-
- public GuildMemberAddResponder(
- IDiscordRestChannelAPI channelApi, GuildDataService dataService, IDiscordRestGuildAPI guildApi) {
- _channelApi = channelApi;
- _dataService = dataService;
- _guildApi = guildApi;
- }
-
- public async Task RespondAsync(IGuildMemberAdd gatewayEvent, CancellationToken ct = default) {
- if (!gatewayEvent.User.IsDefined(out var user))
- return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.User)));
- var data = await _dataService.GetData(gatewayEvent.GuildID, ct);
- var cfg = data.Settings;
- if (GuildSettings.PublicFeedbackChannel.Get(cfg).Empty()
- || GuildSettings.WelcomeMessage.Get(cfg) is "off" or "disable" or "disabled")
- return Result.FromSuccess();
- if (GuildSettings.ReturnRolesOnRejoin.Get(cfg)) {
- var result = await _guildApi.ModifyGuildMemberAsync(
- gatewayEvent.GuildID, user.ID,
- roles: data.GetMemberData(user.ID).Roles.ConvertAll(r => r.ToSnowflake()), ct: ct);
- if (!result.IsSuccess) return Result.FromError(result.Error);
- }
-
- Messages.Culture = GuildSettings.Language.Get(cfg);
- var welcomeMessage = GuildSettings.WelcomeMessage.Get(cfg) is "default" or "reset"
- ? Messages.DefaultWelcomeMessage
- : GuildSettings.WelcomeMessage.Get(cfg);
-
- var guildResult = await _guildApi.GetGuildAsync(gatewayEvent.GuildID, ct: ct);
- if (!guildResult.IsDefined(out var guild)) return Result.FromError(guildResult);
-
- var embed = new EmbedBuilder()
- .WithSmallTitle(string.Format(welcomeMessage, user.GetTag(), guild.Name), user)
- .WithGuildFooter(guild)
- .WithTimestamp(gatewayEvent.JoinedAt)
- .WithColour(ColorsList.Green)
- .Build();
- if (!embed.IsDefined(out var built)) return Result.FromError(embed);
-
- return (Result)await _channelApi.CreateMessageAsync(
- GuildSettings.PublicFeedbackChannel.Get(cfg), embeds: new[] { built },
- allowedMentions: Boyfriend.NoMentions, ct: ct);
- }
-}
-
-///
-/// Handles sending a notification when a scheduled event has been cancelled
-/// in a guild's if one is set.
-///
-public class GuildScheduledEventDeleteResponder : IResponder {
- private readonly IDiscordRestChannelAPI _channelApi;
- private readonly GuildDataService _dataService;
-
- public GuildScheduledEventDeleteResponder(IDiscordRestChannelAPI channelApi, GuildDataService dataService) {
- _channelApi = channelApi;
- _dataService = dataService;
- }
-
- public async Task RespondAsync(IGuildScheduledEventDelete gatewayEvent, CancellationToken ct = default) {
- var guildData = await _dataService.GetData(gatewayEvent.GuildID, ct);
- guildData.ScheduledEvents.Remove(gatewayEvent.ID.Value);
-
- if (GuildSettings.EventNotificationChannel.Get(guildData.Settings).Empty())
- return Result.FromSuccess();
-
- var embed = new EmbedBuilder()
- .WithSmallTitle(string.Format(Messages.EventCancelled, gatewayEvent.Name))
- .WithDescription(":(")
- .WithColour(ColorsList.Red)
- .WithCurrentTimestamp()
- .Build();
-
- if (!embed.IsDefined(out var built)) return Result.FromError(embed);
-
- return (Result)await _channelApi.CreateMessageAsync(
- GuildSettings.EventNotificationChannel.Get(guildData.Settings), embeds: new[] { built }, ct: ct);
- }
-}
-
-///
-/// Handles updating when a guild member is updated.
-///
-public class GuildMemberUpdateResponder : IResponder {
- private readonly GuildDataService _dataService;
-
- public GuildMemberUpdateResponder(GuildDataService dataService) {
- _dataService = dataService;
- }
-
- public async Task RespondAsync(IGuildMemberUpdate gatewayEvent, CancellationToken ct = default) {
- var memberData = await _dataService.GetMemberData(gatewayEvent.GuildID, gatewayEvent.User.ID, ct);
- memberData.Roles = gatewayEvent.Roles.ToList().ConvertAll(r => r.Value);
- return Result.FromSuccess();
- }
-}
-
-///
-/// Handles sending replies to easter egg messages.
-///
-public class MessageCreateResponder : IResponder {
- private readonly IDiscordRestChannelAPI _channelApi;
-
- public MessageCreateResponder(IDiscordRestChannelAPI channelApi) {
- _channelApi = channelApi;
- }
-
- public Task RespondAsync(IMessageCreate gatewayEvent, CancellationToken ct = default) {
- _ = _channelApi.CreateMessageAsync(
- gatewayEvent.ChannelID, ct: ct, content: gatewayEvent.Content switch {
- "whoami" => "`nobody`",
- "сука !!" => "`root`",
- "воооо" => "`removing /...`",
- "пон" =>
- "https://cdn.discordapp.com/attachments/837385840946053181/1087236080950055023/vUORS10xPaY-1.jpg",
- "++++" => "#",
- "осу" => "https://github.com/ppy/osu",
- _ => default(Optional)
- });
- return Task.FromResult(Result.FromSuccess());
- }
-}
diff --git a/src/Extensions.cs b/src/Extensions.cs
index 0f9a786..eab0afc 100644
--- a/src/Extensions.cs
+++ b/src/Extensions.cs
@@ -1,7 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Text;
-using Boyfriend.locale;
using DiffPlex.DiffBuilder.Model;
using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Objects;
diff --git a/src/InteractionResponders.cs b/src/InteractionResponders.cs
index 231df31..49af44c 100644
--- a/src/InteractionResponders.cs
+++ b/src/InteractionResponders.cs
@@ -4,9 +4,6 @@ using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Interactivity;
using Remora.Results;
-// ReSharper disable ClassNeverInstantiated.Global
-// ReSharper disable UnusedMember.Global
-
namespace Boyfriend;
///
diff --git a/locale/Messages.Designer.cs b/src/Messages.Designer.cs
similarity index 99%
rename from locale/Messages.Designer.cs
rename to src/Messages.Designer.cs
index 8ab20f8..42a05be 100644
--- a/locale/Messages.Designer.cs
+++ b/src/Messages.Designer.cs
@@ -7,7 +7,7 @@
//
//------------------------------------------------------------------------------
-namespace Boyfriend.locale {
+namespace Boyfriend {
[System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
diff --git a/src/Responders/GuildLoadedResponder.cs b/src/Responders/GuildLoadedResponder.cs
new file mode 100644
index 0000000..081f526
--- /dev/null
+++ b/src/Responders/GuildLoadedResponder.cs
@@ -0,0 +1,63 @@
+using Boyfriend.Data;
+using Boyfriend.Services;
+using JetBrains.Annotations;
+using Microsoft.Extensions.Logging;
+using Remora.Discord.API.Abstractions.Gateway.Events;
+using Remora.Discord.API.Abstractions.Rest;
+using Remora.Discord.API.Gateway.Events;
+using Remora.Discord.Extensions.Embeds;
+using Remora.Discord.Gateway.Responders;
+using Remora.Results;
+
+namespace Boyfriend.Responders;
+
+///
+/// Handles sending a message to a guild that has just initialized if that guild
+/// has enabled
+///
+[UsedImplicitly]
+public class GuildLoadedResponder : IResponder {
+ private readonly IDiscordRestChannelAPI _channelApi;
+ private readonly GuildDataService _dataService;
+ private readonly ILogger _logger;
+ private readonly IDiscordRestUserAPI _userApi;
+
+ public GuildLoadedResponder(
+ IDiscordRestChannelAPI channelApi, GuildDataService dataService, ILogger logger,
+ IDiscordRestUserAPI userApi) {
+ _channelApi = channelApi;
+ _dataService = dataService;
+ _logger = logger;
+ _userApi = userApi;
+ }
+
+ public async Task RespondAsync(IGuildCreate gatewayEvent, CancellationToken ct = default) {
+ if (!gatewayEvent.Guild.IsT0) return Result.FromSuccess(); // Guild is not IAvailableGuild
+
+ var guild = gatewayEvent.Guild.AsT0;
+ _logger.LogInformation("Joined guild \"{Name}\"", guild.Name);
+
+ var cfg = await _dataService.GetSettings(guild.ID, ct);
+ if (!GuildSettings.ReceiveStartupMessages.Get(cfg))
+ return Result.FromSuccess();
+ if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty())
+ return Result.FromSuccess();
+
+ var currentUserResult = await _userApi.GetCurrentUserAsync(ct);
+ if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult);
+
+ Messages.Culture = GuildSettings.Language.Get(cfg);
+ var i = Random.Shared.Next(1, 4);
+
+ var embed = new EmbedBuilder().WithSmallTitle(currentUser.GetTag(), currentUser)
+ .WithTitle($"Beep{i}".Localized())
+ .WithDescription(Messages.Ready)
+ .WithCurrentTimestamp()
+ .WithColour(ColorsList.Blue)
+ .Build();
+ if (!embed.IsDefined(out var built)) return Result.FromError(embed);
+
+ return (Result)await _channelApi.CreateMessageAsync(
+ GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: new[] { built }, ct: ct);
+ }
+}
diff --git a/src/Responders/GuildMemberJoinedResponder.cs b/src/Responders/GuildMemberJoinedResponder.cs
new file mode 100644
index 0000000..c61e500
--- /dev/null
+++ b/src/Responders/GuildMemberJoinedResponder.cs
@@ -0,0 +1,65 @@
+using Boyfriend.Data;
+using Boyfriend.Services;
+using JetBrains.Annotations;
+using Remora.Discord.API.Abstractions.Gateway.Events;
+using Remora.Discord.API.Abstractions.Rest;
+using Remora.Discord.Extensions.Embeds;
+using Remora.Discord.Gateway.Responders;
+using Remora.Results;
+
+namespace Boyfriend.Responders;
+
+///
+/// Handles sending a guild's if one is set.
+/// If is enabled, roles will be returned.
+///
+///
+[UsedImplicitly]
+public class GuildMemberJoinedResponder : IResponder {
+ private readonly IDiscordRestChannelAPI _channelApi;
+ private readonly GuildDataService _dataService;
+ private readonly IDiscordRestGuildAPI _guildApi;
+
+ public GuildMemberJoinedResponder(
+ IDiscordRestChannelAPI channelApi, GuildDataService dataService, IDiscordRestGuildAPI guildApi) {
+ _channelApi = channelApi;
+ _dataService = dataService;
+ _guildApi = guildApi;
+ }
+
+ public async Task RespondAsync(IGuildMemberAdd gatewayEvent, CancellationToken ct = default) {
+ if (!gatewayEvent.User.IsDefined(out var user))
+ return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.User)));
+ var data = await _dataService.GetData(gatewayEvent.GuildID, ct);
+ var cfg = data.Settings;
+ if (GuildSettings.PublicFeedbackChannel.Get(cfg).Empty()
+ || GuildSettings.WelcomeMessage.Get(cfg) is "off" or "disable" or "disabled")
+ return Result.FromSuccess();
+ if (GuildSettings.ReturnRolesOnRejoin.Get(cfg)) {
+ var result = await _guildApi.ModifyGuildMemberAsync(
+ gatewayEvent.GuildID, user.ID,
+ roles: data.GetMemberData(user.ID).Roles.ConvertAll(r => r.ToSnowflake()), ct: ct);
+ if (!result.IsSuccess) return Result.FromError(result.Error);
+ }
+
+ Messages.Culture = GuildSettings.Language.Get(cfg);
+ var welcomeMessage = GuildSettings.WelcomeMessage.Get(cfg) is "default" or "reset"
+ ? Messages.DefaultWelcomeMessage
+ : GuildSettings.WelcomeMessage.Get(cfg);
+
+ var guildResult = await _guildApi.GetGuildAsync(gatewayEvent.GuildID, ct: ct);
+ if (!guildResult.IsDefined(out var guild)) return Result.FromError(guildResult);
+
+ var embed = new EmbedBuilder()
+ .WithSmallTitle(string.Format(welcomeMessage, user.GetTag(), guild.Name), user)
+ .WithGuildFooter(guild)
+ .WithTimestamp(gatewayEvent.JoinedAt)
+ .WithColour(ColorsList.Green)
+ .Build();
+ if (!embed.IsDefined(out var built)) return Result.FromError(embed);
+
+ return (Result)await _channelApi.CreateMessageAsync(
+ GuildSettings.PublicFeedbackChannel.Get(cfg), embeds: new[] { built },
+ allowedMentions: Boyfriend.NoMentions, ct: ct);
+ }
+}
diff --git a/src/Responders/GuildMemberRolesUpdatedResponder.cs b/src/Responders/GuildMemberRolesUpdatedResponder.cs
new file mode 100644
index 0000000..b61ce32
--- /dev/null
+++ b/src/Responders/GuildMemberRolesUpdatedResponder.cs
@@ -0,0 +1,26 @@
+using Boyfriend.Data;
+using Boyfriend.Services;
+using JetBrains.Annotations;
+using Remora.Discord.API.Abstractions.Gateway.Events;
+using Remora.Discord.Gateway.Responders;
+using Remora.Results;
+
+namespace Boyfriend.Responders;
+
+///
+/// Handles updating when a guild member is updated.
+///
+[UsedImplicitly]
+public class GuildMemberUpdateResponder : IResponder {
+ private readonly GuildDataService _dataService;
+
+ public GuildMemberUpdateResponder(GuildDataService dataService) {
+ _dataService = dataService;
+ }
+
+ public async Task RespondAsync(IGuildMemberUpdate gatewayEvent, CancellationToken ct = default) {
+ var memberData = await _dataService.GetMemberData(gatewayEvent.GuildID, gatewayEvent.User.ID, ct);
+ memberData.Roles = gatewayEvent.Roles.ToList().ConvertAll(r => r.Value);
+ return Result.FromSuccess();
+ }
+}
diff --git a/src/Responders/MessageDeletedResponder.cs b/src/Responders/MessageDeletedResponder.cs
new file mode 100644
index 0000000..903db84
--- /dev/null
+++ b/src/Responders/MessageDeletedResponder.cs
@@ -0,0 +1,78 @@
+using Boyfriend.Data;
+using Boyfriend.Services;
+using JetBrains.Annotations;
+using Remora.Discord.API.Abstractions.Gateway.Events;
+using Remora.Discord.API.Abstractions.Objects;
+using Remora.Discord.API.Abstractions.Rest;
+using Remora.Discord.Extensions.Embeds;
+using Remora.Discord.Extensions.Formatting;
+using Remora.Discord.Gateway.Responders;
+using Remora.Results;
+
+namespace Boyfriend.Responders;
+
+///
+/// Handles logging the contents of a deleted message and the user who deleted the message
+/// to a guild's if one is set.
+///
+[UsedImplicitly]
+public class MessageDeletedResponder : IResponder {
+ private readonly IDiscordRestAuditLogAPI _auditLogApi;
+ private readonly IDiscordRestChannelAPI _channelApi;
+ private readonly GuildDataService _dataService;
+ private readonly IDiscordRestUserAPI _userApi;
+
+ public MessageDeletedResponder(
+ IDiscordRestAuditLogAPI auditLogApi, IDiscordRestChannelAPI channelApi,
+ GuildDataService dataService, IDiscordRestUserAPI userApi) {
+ _auditLogApi = auditLogApi;
+ _channelApi = channelApi;
+ _dataService = dataService;
+ _userApi = userApi;
+ }
+
+ public async Task RespondAsync(IMessageDelete gatewayEvent, CancellationToken ct = default) {
+ if (!gatewayEvent.GuildID.IsDefined(out var guildId)) return Result.FromSuccess();
+
+ var cfg = await _dataService.GetSettings(guildId, ct);
+ if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()) return Result.FromSuccess();
+
+ var messageResult = await _channelApi.GetChannelMessageAsync(gatewayEvent.ChannelID, gatewayEvent.ID, ct);
+ if (!messageResult.IsDefined(out var message)) return Result.FromError(messageResult);
+ if (string.IsNullOrWhiteSpace(message.Content)) return Result.FromSuccess();
+
+ var auditLogResult = await _auditLogApi.GetGuildAuditLogAsync(
+ guildId, actionType: AuditLogEvent.MessageDelete, limit: 1, ct: ct);
+ if (!auditLogResult.IsDefined(out var auditLogPage)) return Result.FromError(auditLogResult);
+
+ var auditLog = auditLogPage.AuditLogEntries.Single();
+ if (!auditLog.Options.IsDefined(out var options))
+ return Result.FromError(new ArgumentNullError(nameof(auditLog.Options)));
+
+ var user = message.Author;
+ if (options.ChannelID == gatewayEvent.ChannelID
+ && DateTimeOffset.UtcNow.Subtract(auditLog.ID.Timestamp).TotalSeconds <= 2) {
+ var userResult = await _userApi.GetUserAsync(auditLog.UserID!.Value, ct);
+ if (!userResult.IsDefined(out user)) return Result.FromError(userResult);
+ }
+
+ Messages.Culture = GuildSettings.Language.Get(cfg);
+
+ var embed = new EmbedBuilder()
+ .WithSmallTitle(
+ string.Format(
+ Messages.CachedMessageDeleted,
+ message.Author.GetTag()), message.Author)
+ .WithDescription(
+ $"{Mention.Channel(gatewayEvent.ChannelID)}\n{message.Content.InBlockCode()}")
+ .WithActionFooter(user)
+ .WithTimestamp(message.Timestamp)
+ .WithColour(ColorsList.Red)
+ .Build();
+ if (!embed.IsDefined(out var built)) return Result.FromError(embed);
+
+ return (Result)await _channelApi.CreateMessageAsync(
+ GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: new[] { built },
+ allowedMentions: Boyfriend.NoMentions, ct: ct);
+ }
+}
diff --git a/src/Responders/MessageEditedResponder.cs b/src/Responders/MessageEditedResponder.cs
new file mode 100644
index 0000000..0211170
--- /dev/null
+++ b/src/Responders/MessageEditedResponder.cs
@@ -0,0 +1,89 @@
+using Boyfriend.Data;
+using Boyfriend.Services;
+using DiffPlex.DiffBuilder;
+using JetBrains.Annotations;
+using Remora.Discord.API.Abstractions.Gateway.Events;
+using Remora.Discord.API.Abstractions.Objects;
+using Remora.Discord.API.Abstractions.Rest;
+using Remora.Discord.Caching;
+using Remora.Discord.Caching.Services;
+using Remora.Discord.Extensions.Embeds;
+using Remora.Discord.Gateway.Responders;
+using Remora.Results;
+
+namespace Boyfriend.Responders;
+
+///
+/// Handles logging the difference between an edited message's old and new content
+/// to a guild's if one is set.
+///
+[UsedImplicitly]
+public class MessageEditedResponder : IResponder {
+ private readonly CacheService _cacheService;
+ private readonly IDiscordRestChannelAPI _channelApi;
+ private readonly GuildDataService _dataService;
+ private readonly IDiscordRestUserAPI _userApi;
+
+ public MessageEditedResponder(
+ CacheService cacheService, IDiscordRestChannelAPI channelApi, GuildDataService dataService,
+ IDiscordRestUserAPI userApi) {
+ _cacheService = cacheService;
+ _channelApi = channelApi;
+ _dataService = dataService;
+ _userApi = userApi;
+ }
+
+ public async Task RespondAsync(IMessageUpdate gatewayEvent, CancellationToken ct = default) {
+ if (!gatewayEvent.GuildID.IsDefined(out var guildId))
+ return Result.FromSuccess();
+ var cfg = await _dataService.GetSettings(guildId, ct);
+ if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty())
+ return Result.FromSuccess();
+ if (!gatewayEvent.Content.IsDefined(out var newContent))
+ return Result.FromSuccess();
+ if (!gatewayEvent.EditedTimestamp.IsDefined(out var timestamp))
+ return Result.FromSuccess(); // The message wasn't actually edited
+
+ if (!gatewayEvent.ChannelID.IsDefined(out var channelId))
+ return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.ChannelID)));
+ if (!gatewayEvent.ID.IsDefined(out var messageId))
+ return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.ID)));
+
+ var cacheKey = new KeyHelpers.MessageCacheKey(channelId, messageId);
+ var messageResult = await _cacheService.TryGetValueAsync(
+ cacheKey, ct);
+ if (!messageResult.IsDefined(out var message)) return Result.FromError(messageResult);
+ if (message.Content == newContent) return Result.FromSuccess();
+
+ // Custom event responders are called earlier than responders responsible for message caching
+ // This means that subsequent edit logs may contain the wrong content
+ // We can work around this by evicting the message from the cache
+ await _cacheService.EvictAsync(cacheKey, ct);
+ // However, since we evicted the message, subsequent edits won't have a cached instance to work with
+ // Getting the message will put it back in the cache, resolving all issues
+ // We don't need to await this since the result is not needed
+ // NOTE: Because this is not awaited, there may be a race condition depending on how fast clients are able to edit their messages
+ // NOTE: Awaiting this might not even solve this if the same responder is called asynchronously
+ _ = _channelApi.GetChannelMessageAsync(channelId, messageId, ct);
+
+ var currentUserResult = await _userApi.GetCurrentUserAsync(ct);
+ if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult);
+
+ var diff = InlineDiffBuilder.Diff(message.Content, newContent);
+
+ Messages.Culture = GuildSettings.Language.Get(cfg);
+
+ var embed = new EmbedBuilder()
+ .WithSmallTitle(string.Format(Messages.CachedMessageEdited, message.Author.GetTag()), message.Author)
+ .WithDescription($"https://discord.com/channels/{guildId}/{channelId}/{messageId}\n{diff.AsMarkdown()}")
+ .WithUserFooter(currentUser)
+ .WithTimestamp(timestamp.Value)
+ .WithColour(ColorsList.Yellow)
+ .Build();
+ if (!embed.IsDefined(out var built)) return Result.FromError(embed);
+
+ return (Result)await _channelApi.CreateMessageAsync(
+ GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: new[] { built },
+ allowedMentions: Boyfriend.NoMentions, ct: ct);
+ }
+}
diff --git a/src/Responders/MessageReceivedResponder.cs b/src/Responders/MessageReceivedResponder.cs
new file mode 100644
index 0000000..baaae04
--- /dev/null
+++ b/src/Responders/MessageReceivedResponder.cs
@@ -0,0 +1,35 @@
+using JetBrains.Annotations;
+using Remora.Discord.API.Abstractions.Gateway.Events;
+using Remora.Discord.API.Abstractions.Rest;
+using Remora.Discord.Gateway.Responders;
+using Remora.Rest.Core;
+using Remora.Results;
+
+namespace Boyfriend.Responders;
+
+///
+/// Handles sending replies to easter egg messages.
+///
+[UsedImplicitly]
+public class MessageCreateResponder : IResponder {
+ private readonly IDiscordRestChannelAPI _channelApi;
+
+ public MessageCreateResponder(IDiscordRestChannelAPI channelApi) {
+ _channelApi = channelApi;
+ }
+
+ public Task RespondAsync(IMessageCreate gatewayEvent, CancellationToken ct = default) {
+ _ = _channelApi.CreateMessageAsync(
+ gatewayEvent.ChannelID, ct: ct, content: gatewayEvent.Content switch {
+ "whoami" => "`nobody`",
+ "сука !!" => "`root`",
+ "воооо" => "`removing /...`",
+ "пон" =>
+ "https://cdn.discordapp.com/attachments/837385840946053181/1087236080950055023/vUORS10xPaY-1.jpg",
+ "++++" => "#",
+ "осу" => "https://github.com/ppy/osu",
+ _ => default(Optional)
+ });
+ return Task.FromResult(Result.FromSuccess());
+ }
+}
diff --git a/src/Responders/ScheduledEventCancelledResponder.cs b/src/Responders/ScheduledEventCancelledResponder.cs
new file mode 100644
index 0000000..86453ef
--- /dev/null
+++ b/src/Responders/ScheduledEventCancelledResponder.cs
@@ -0,0 +1,45 @@
+using Boyfriend.Data;
+using Boyfriend.Services;
+using JetBrains.Annotations;
+using Remora.Discord.API.Abstractions.Gateway.Events;
+using Remora.Discord.API.Abstractions.Rest;
+using Remora.Discord.Extensions.Embeds;
+using Remora.Discord.Gateway.Responders;
+using Remora.Results;
+
+namespace Boyfriend.Responders;
+
+///
+/// Handles sending a notification when a scheduled event has been cancelled
+/// in a guild's if one is set.
+///
+[UsedImplicitly]
+public class GuildScheduledEventDeleteResponder : IResponder {
+ private readonly IDiscordRestChannelAPI _channelApi;
+ private readonly GuildDataService _dataService;
+
+ public GuildScheduledEventDeleteResponder(IDiscordRestChannelAPI channelApi, GuildDataService dataService) {
+ _channelApi = channelApi;
+ _dataService = dataService;
+ }
+
+ public async Task RespondAsync(IGuildScheduledEventDelete gatewayEvent, CancellationToken ct = default) {
+ var guildData = await _dataService.GetData(gatewayEvent.GuildID, ct);
+ guildData.ScheduledEvents.Remove(gatewayEvent.ID.Value);
+
+ if (GuildSettings.EventNotificationChannel.Get(guildData.Settings).Empty())
+ return Result.FromSuccess();
+
+ var embed = new EmbedBuilder()
+ .WithSmallTitle(string.Format(Messages.EventCancelled, gatewayEvent.Name))
+ .WithDescription(":(")
+ .WithColour(ColorsList.Red)
+ .WithCurrentTimestamp()
+ .Build();
+
+ if (!embed.IsDefined(out var built)) return Result.FromError(embed);
+
+ return (Result)await _channelApi.CreateMessageAsync(
+ GuildSettings.EventNotificationChannel.Get(guildData.Settings), embeds: new[] { built }, ct: ct);
+ }
+}
diff --git a/src/Services/GuildUpdateService.cs b/src/Services/GuildUpdateService.cs
index e315deb..99b94c1 100644
--- a/src/Services/GuildUpdateService.cs
+++ b/src/Services/GuildUpdateService.cs
@@ -1,6 +1,5 @@
using System.Text.Json.Nodes;
using Boyfriend.Data;
-using Boyfriend.locale;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Remora.Discord.API.Abstractions.Objects;