1
0
Fork 1
mirror of https://github.com/TeamOctolings/Octobot.git synced 2025-05-02 12:09:53 +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,8 @@
namespace TeamOctolings.Octobot.Attributes;
/// <summary>
/// Any property marked with <see cref="StaticCallersOnlyAttribute"/> should only be accessed by static methods.
/// Such properties may be used to provide dependencies where it is not possible to acquire them through normal means.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public sealed class StaticCallersOnlyAttribute : Attribute;

View file

@ -0,0 +1,18 @@
namespace TeamOctolings.Octobot;
public static class BuildInfo
{
public const string RepositoryUrl = "https://github.com/TeamOctolings/Octobot";
public const string IssuesUrl = $"{RepositoryUrl}/issues";
public const string WikiUrl = $"{RepositoryUrl}/wiki";
private const string Commit = ThisAssembly.Git.Commit;
private const string Branch = ThisAssembly.Git.Branch;
public static bool IsDirty => ThisAssembly.Git.IsDirty;
public static string Version => IsDirty ? $"{Branch}-{Commit}-dirty" : $"{Branch}-{Commit}";
}

View file

@ -0,0 +1,19 @@
using System.Drawing;
namespace TeamOctolings.Octobot;
/// <summary>
/// Contains all colors used in embeds.
/// </summary>
public static class ColorsList
{
public static readonly Color Default = Color.Gray;
public static readonly Color Red = Color.Firebrick;
public static readonly Color Green = Color.PaleGreen;
public static readonly Color Yellow = Color.Gold;
public static readonly Color Blue = Color.RoyalBlue;
public static readonly Color Magenta = Color.Orchid;
public static readonly Color Cyan = Color.LightSkyBlue;
public static readonly Color Black = Color.Black;
public static readonly Color White = Color.WhiteSmoke;
}

View file

@ -0,0 +1,137 @@
using System.ComponentModel;
using System.Text;
using JetBrains.Annotations;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects;
using Remora.Discord.Commands.Attributes;
using Remora.Discord.Commands.Conditions;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Messages;
using Remora.Discord.Commands.Feedback.Services;
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;
using TeamOctolings.Octobot.Services;
namespace TeamOctolings.Octobot.Commands;
/// <summary>
/// Handles the command to show information about this bot: /about.
/// </summary>
[UsedImplicitly]
public class AboutCommandGroup : CommandGroup
{
private static readonly (string Username, Snowflake Id)[] Developers =
[
("Octol1ttle", new Snowflake(504343489664909322)),
("mctaylors", new Snowflake(326642240229474304)),
("neroduckale", new Snowflake(474943797063843851))
];
private readonly ICommandContext _context;
private readonly IFeedbackService _feedback;
private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi;
private readonly IDiscordRestGuildAPI _guildApi;
public AboutCommandGroup(
ICommandContext context, GuildDataService guildData,
IFeedbackService feedback, IDiscordRestUserAPI userApi,
IDiscordRestGuildAPI guildApi)
{
_context = context;
_guildData = guildData;
_feedback = feedback;
_userApi = userApi;
_guildApi = guildApi;
}
/// <summary>
/// A slash command that shows information about this bot.
/// </summary>
/// <returns>
/// A feedback sending result which may or may not have succeeded.
/// </returns>
[Command("about")]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[Description("Shows Octobot's developers")]
[UsedImplicitly]
public async Task<Result> ExecuteAboutAsync()
{
if (!_context.TryGetContextIDs(out var guildId, out _, out _))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var cfg = await _guildData.GetSettings(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(cfg);
return await SendAboutBotAsync(bot, guildId, CancellationToken);
}
private async Task<Result> SendAboutBotAsync(IUser bot, Snowflake guildId, CancellationToken ct = default)
{
var builder = new StringBuilder().Append("### ").AppendLine(Messages.AboutTitleDevelopers);
foreach (var dev in Developers)
{
var guildMemberResult = await _guildApi.GetGuildMemberAsync(
guildId, dev.Id, ct);
var tag = guildMemberResult.IsSuccess
? $"<@{dev.Id}>"
: Markdown.Hyperlink($"@{dev.Username}", $"https://github.com/{dev.Username}");
builder.AppendBulletPointLine($"{tag} — {$"AboutDeveloper@{dev.Username}".Localized()}");
}
var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(Messages.AboutBot, bot.Username), bot)
.WithDescription(builder.ToString())
.WithColour(ColorsList.Cyan)
.WithImageUrl("https://raw.githubusercontent.com/TeamOctolings/Octobot/HEAD/docs/octobot-banner.png")
.WithFooter(string.Format(Messages.Version, BuildInfo.Version))
.Build();
var repositoryButton = new ButtonComponent(
ButtonComponentStyle.Link,
Messages.ButtonOpenRepository,
new PartialEmoji(Name: "\ud83c\udf10"), // 'GLOBE WITH MERIDIANS' (U+1F310)
URL: BuildInfo.RepositoryUrl
);
var wikiButton = new ButtonComponent(
ButtonComponentStyle.Link,
Messages.ButtonOpenWiki,
new PartialEmoji(Name: "\ud83d\udcd6"), // 'OPEN BOOK' (U+1F4D6)
URL: BuildInfo.WikiUrl
);
var issuesButton = new ButtonComponent(
ButtonComponentStyle.Link,
BuildInfo.IsDirty
? Messages.ButtonDirty
: Messages.ButtonReportIssue,
new PartialEmoji(Name: "\u26a0\ufe0f"), // 'WARNING SIGN' (U+26A0)
URL: BuildInfo.IssuesUrl,
IsDisabled: BuildInfo.IsDirty
);
return await _feedback.SendContextualEmbedResultAsync(embed,
new FeedbackMessageOptions(MessageComponents: new[]
{
new ActionRowComponent(new[] { repositoryButton, wikiButton, issuesButton })
}), ct);
}
}

View file

@ -0,0 +1,300 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Text;
using JetBrains.Annotations;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Commands.Attributes;
using Remora.Discord.Commands.Conditions;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Services;
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;
using TeamOctolings.Octobot.Parsers;
using TeamOctolings.Octobot.Services;
using TeamOctolings.Octobot.Services.Update;
namespace TeamOctolings.Octobot.Commands;
/// <summary>
/// Handles commands related to ban management: /ban and /unban.
/// </summary>
[UsedImplicitly]
public class BanCommandGroup : CommandGroup
{
private readonly AccessControlService _access;
private readonly IDiscordRestChannelAPI _channelApi;
private readonly ICommandContext _context;
private readonly IFeedbackService _feedback;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi;
private readonly Utility _utility;
public BanCommandGroup(AccessControlService access, IDiscordRestChannelAPI channelApi, ICommandContext context,
IFeedbackService feedback, IDiscordRestGuildAPI guildApi, GuildDataService guildData,
IDiscordRestUserAPI userApi, Utility utility)
{
_access = access;
_channelApi = channelApi;
_context = context;
_feedback = feedback;
_guildApi = guildApi;
_guildData = guildData;
_userApi = userApi;
_utility = utility;
}
/// <summary>
/// A slash command that bans a Discord user with the specified reason.
/// </summary>
/// <param name="target">The user to ban.</param>
/// <param name="duration">The duration for this ban. The user will be automatically unbanned after this duration.</param>
/// <param name="reason">
/// The reason for this ban. Must be encoded with <see cref="StringExtensions.EncodeHeader" /> when passed to
/// <see cref="IDiscordRestGuildAPI.CreateGuildBanAsync" />.
/// </param>
/// <returns>
/// A feedback sending result which may or may not have succeeded. A successful result does not mean that the user
/// was banned and vice-versa.
/// </returns>
/// <seealso cref="ExecuteUnban" />
[Command("ban", "бан")]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ManageMessages)]
[RequireBotDiscordPermissions(DiscordPermission.BanMembers)]
[Description("Ban user")]
[UsedImplicitly]
public async Task<Result> ExecuteBanAsync(
[Description("User to ban")] IUser target,
[Description("Ban reason")] [MaxLength(256)]
string reason,
[Description("Ban duration (e.g. 1h30m)")]
string? duration = null)
{
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
// The bot's avatar is used when sending error messages
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return ResultExtensions.FromError(executorResult);
}
var guildResult = await _guildApi.GetGuildAsync(guildId, ct: CancellationToken);
if (!guildResult.IsDefined(out var guild))
{
return ResultExtensions.FromError(guildResult);
}
var data = await _guildData.GetData(guild.ID, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
if (duration is null)
{
return await BanUserAsync(executor, target, reason, null, guild, data, channelId, bot,
CancellationToken);
}
var parseResult = TimeSpanParser.TryParse(duration);
if (!parseResult.IsDefined(out var timeSpan))
{
var failedEmbed = new EmbedBuilder()
.WithSmallTitle(Messages.InvalidTimeSpan, bot)
.WithDescription(Messages.TimeSpanExample)
.WithColour(ColorsList.Red)
.Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: CancellationToken);
}
return await BanUserAsync(executor, target, reason, timeSpan, guild, data, channelId, bot, CancellationToken);
}
private async Task<Result> BanUserAsync(
IUser executor, IUser target, string reason, TimeSpan? duration, IGuild guild, GuildData data,
Snowflake channelId,
IUser bot, CancellationToken ct = default)
{
var existingBanResult = await _guildApi.GetGuildBanAsync(guild.ID, target.ID, ct);
if (existingBanResult.IsDefined())
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.UserAlreadyBanned, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var interactionResult
= await _access.CheckInteractionsAsync(guild.ID, executor.ID, target.ID, "Ban", ct);
if (!interactionResult.IsSuccess)
{
return ResultExtensions.FromError(interactionResult);
}
if (interactionResult.Entity is not null)
{
var errorEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(errorEmbed, ct: ct);
}
var builder =
new StringBuilder().AppendBulletPointLine(string.Format(Messages.DescriptionActionReason, reason));
if (duration is not null)
{
builder.AppendBulletPoint(
string.Format(
Messages.DescriptionActionExpiresAt,
Markdown.Timestamp(DateTimeOffset.UtcNow.Add(duration.Value))));
}
var title = string.Format(Messages.UserBanned, target.GetTag());
var description = builder.ToString();
var dmChannelResult = await _userApi.CreateDMAsync(target.ID, ct);
if (dmChannelResult.IsDefined(out var dmChannel))
{
var dmEmbed = new EmbedBuilder().WithGuildTitle(guild)
.WithTitle(Messages.YouWereBanned)
.WithDescription(description)
.WithActionFooter(executor)
.WithCurrentTimestamp()
.WithColour(ColorsList.Red)
.Build();
await _channelApi.CreateMessageWithEmbedResultAsync(dmChannel.ID, embedResult: dmEmbed, ct: ct);
}
var memberData = data.GetOrCreateMemberData(target.ID);
memberData.BannedUntil
= duration is not null ? DateTimeOffset.UtcNow.Add(duration.Value) : DateTimeOffset.MaxValue;
var banResult = await _guildApi.CreateGuildBanAsync(
guild.ID, target.ID, reason: $"({executor.GetTag()}) {reason}".EncodeHeader(),
ct: ct);
if (!banResult.IsSuccess)
{
memberData.BannedUntil = null;
return ResultExtensions.FromError(banResult);
}
memberData.Roles.Clear();
var embed = new EmbedBuilder().WithSmallTitle(
title, target)
.WithColour(ColorsList.Green).Build();
_utility.LogAction(
data.Settings, channelId, executor, title, description, target, ColorsList.Red, ct: ct);
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
/// <summary>
/// A slash command that unbans a Discord user with the specified reason.
/// </summary>
/// <param name="target">The user to unban.</param>
/// <param name="reason">
/// The reason for this unban. Must be encoded with <see cref="StringExtensions.EncodeHeader" /> when passed to
/// <see cref="IDiscordRestGuildAPI.RemoveGuildBanAsync" />.
/// </param>
/// <returns>
/// A feedback sending result which may or may not have succeeded. A successful result does not mean that the user
/// was unbanned and vice-versa.
/// </returns>
/// <seealso cref="ExecuteBanAsync" />
/// <seealso cref="MemberUpdateService.TickMemberDataAsync" />
[Command("unban")]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ManageMessages)]
[RequireBotDiscordPermissions(DiscordPermission.BanMembers)]
[Description("Unban user")]
[UsedImplicitly]
public async Task<Result> ExecuteUnban(
[Description("User to unban")] IUser target,
[Description("Unban reason")] [MaxLength(256)]
string reason)
{
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
// The bot's avatar is used when sending error messages
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
// Needed to get the tag and avatar
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return ResultExtensions.FromError(executorResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
return await UnbanUserAsync(executor, target, reason, guildId, data, channelId, bot, CancellationToken);
}
private async Task<Result> UnbanUserAsync(
IUser executor, IUser target, string reason, Snowflake guildId, GuildData data, Snowflake channelId,
IUser bot, CancellationToken ct = default)
{
var existingBanResult = await _guildApi.GetGuildBanAsync(guildId, target.ID, ct);
if (!existingBanResult.IsDefined())
{
var errorEmbed = new EmbedBuilder().WithSmallTitle(Messages.UserNotBanned, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(errorEmbed, ct: ct);
}
var unbanResult = await _guildApi.RemoveGuildBanAsync(
guildId, target.ID, $"({executor.GetTag()}) {reason}".EncodeHeader(),
ct);
if (!unbanResult.IsSuccess)
{
return ResultExtensions.FromError(unbanResult);
}
data.GetOrCreateMemberData(target.ID).BannedUntil = null;
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.UserUnbanned, target.GetTag()), target)
.WithColour(ColorsList.Green).Build();
var title = string.Format(Messages.UserUnbanned, target.GetTag());
var description =
new StringBuilder().AppendBulletPoint(string.Format(Messages.DescriptionActionReason, reason));
_utility.LogAction(
data.Settings, channelId, executor, title, description.ToString(), target, ColorsList.Green, ct: ct);
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
}

View file

@ -0,0 +1,169 @@
using System.ComponentModel;
using System.Text;
using JetBrains.Annotations;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Commands.Attributes;
using Remora.Discord.Commands.Conditions;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Services;
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;
using TeamOctolings.Octobot.Services;
namespace TeamOctolings.Octobot.Commands;
/// <summary>
/// Handles the command to clear messages in a channel: /clear.
/// </summary>
[UsedImplicitly]
public class ClearCommandGroup : CommandGroup
{
private readonly IDiscordRestChannelAPI _channelApi;
private readonly ICommandContext _context;
private readonly IFeedbackService _feedback;
private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi;
private readonly Utility _utility;
public ClearCommandGroup(
IDiscordRestChannelAPI channelApi, ICommandContext context, GuildDataService guildData,
IFeedbackService feedback, IDiscordRestUserAPI userApi, Utility utility)
{
_channelApi = channelApi;
_context = context;
_guildData = guildData;
_feedback = feedback;
_userApi = userApi;
_utility = utility;
}
/// <summary>
/// A slash command that clears messages in the channel it was executed, optionally filtering by message author.
/// </summary>
/// <param name="amount">The amount of messages to clear.</param>
/// <param name="author">The user whose messages will be cleared.</param>
/// <returns>
/// A feedback sending result which may or may not have succeeded. A successful result does not mean that any messages
/// were cleared and vice-versa.
/// </returns>
[Command("clear", "очистить")]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ManageMessages)]
[RequireBotDiscordPermissions(DiscordPermission.ManageMessages)]
[Description("Remove multiple messages")]
[UsedImplicitly]
public async Task<Result> ExecuteClear(
[Description("Number of messages to remove (2-100)")] [MinValue(2)] [MaxValue(100)]
int amount,
IUser? author = null)
{
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
// The bot's avatar is used when sending messages
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return ResultExtensions.FromError(executorResult);
}
var messagesResult = await _channelApi.GetChannelMessagesAsync(
channelId, limit: amount + 1, ct: CancellationToken);
if (!messagesResult.IsDefined(out var messages))
{
return ResultExtensions.FromError(messagesResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
return await ClearMessagesAsync(executor, author, data, channelId, messages, bot, CancellationToken);
}
private async Task<Result> ClearMessagesAsync(
IUser executor, IUser? author, GuildData data, Snowflake channelId, IReadOnlyList<IMessage> messages, IUser bot,
CancellationToken ct = default)
{
var idList = new List<Snowflake>(messages.Count);
var logEntries = new List<ClearedMessageEntry> { new() };
var currentLogEntry = 0;
for (var i = messages.Count - 1; i >= 1; i--) // '>= 1' to skip last message ('Octobot is thinking...')
{
var message = messages[i];
if (author is not null && message.Author.ID != author.ID)
{
continue;
}
idList.Add(message.ID);
var entry = logEntries[currentLogEntry];
var str = $"{string.Format(Messages.MessageFrom, Mention.User(message.Author))}\n{message.Content.InBlockCode()}";
if (entry.Builder.Length + str.Length > EmbedConstants.MaxDescriptionLength)
{
logEntries.Add(entry = new ClearedMessageEntry());
currentLogEntry++;
}
entry.Builder.Append(str);
entry.DeletedCount++;
}
if (idList.Count == 0)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.NoMessagesToClear, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var title = author is not null
? string.Format(Messages.MessagesClearedFiltered, idList.Count.ToString(), author.GetTag())
: string.Format(Messages.MessagesCleared, idList.Count.ToString());
var deleteResult = await _channelApi.BulkDeleteMessagesAsync(
channelId, idList, executor.GetTag().EncodeHeader(), ct);
if (!deleteResult.IsSuccess)
{
return ResultExtensions.FromError(deleteResult);
}
foreach (var log in logEntries)
{
_utility.LogAction(
data.Settings, channelId, executor, author is not null
? string.Format(Messages.MessagesClearedFiltered, log.DeletedCount.ToString(), author.GetTag())
: string.Format(Messages.MessagesCleared, log.DeletedCount.ToString()),
log.Builder.ToString(), bot, ColorsList.Red, false, ct);
}
var embed = new EmbedBuilder().WithSmallTitle(title, bot)
.WithColour(ColorsList.Green).Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
private sealed class ClearedMessageEntry
{
public StringBuilder Builder { get; } = new();
public int DeletedCount { get; set; }
}
}

View file

@ -0,0 +1,88 @@
using JetBrains.Annotations;
using Microsoft.Extensions.Logging;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Messages;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Commands.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Results;
using TeamOctolings.Octobot.Extensions;
namespace TeamOctolings.Octobot.Commands.Events;
/// <summary>
/// Handles error logging for slash command groups.
/// </summary>
[UsedImplicitly]
public class ErrorLoggingPostExecutionEvent : IPostExecutionEvent
{
private readonly IFeedbackService _feedback;
private readonly ILogger<ErrorLoggingPostExecutionEvent> _logger;
private readonly IDiscordRestUserAPI _userApi;
public ErrorLoggingPostExecutionEvent(ILogger<ErrorLoggingPostExecutionEvent> logger, IFeedbackService feedback,
IDiscordRestUserAPI userApi)
{
_logger = logger;
_feedback = feedback;
_userApi = userApi;
}
/// <summary>
/// Logs a warning using the injected <see cref="ILogger" /> if the <paramref name="commandResult" /> has not
/// succeeded.
/// </summary>
/// <param name="context">The context of the slash command.</param>
/// <param name="commandResult">The result whose success is checked.</param>
/// <param name="ct">The cancellation token for this operation. Unused.</param>
/// <returns>A result which has succeeded.</returns>
public async Task<Result> AfterExecutionAsync(
ICommandContext context, IResult commandResult, CancellationToken ct = default)
{
_logger.LogResult(commandResult, $"Error in slash command execution for /{context.Command.Command.Node.Key}.");
var result = commandResult;
while (result.Inner is not null)
{
result = result.Inner;
}
if (result.IsSuccess)
{
return Result.Success;
}
var botResult = await _userApi.GetCurrentUserAsync(ct);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var embed = new EmbedBuilder().WithSmallTitle(Messages.CommandExecutionFailed, bot)
.WithDescription(Markdown.InlineCode(result.Error.Message))
.WithFooter(Messages.ContactDevelopers)
.WithColour(ColorsList.Red)
.Build();
var issuesButton = new ButtonComponent(
ButtonComponentStyle.Link,
BuildInfo.IsDirty
? Messages.ButtonDirty
: Messages.ButtonReportIssue,
new PartialEmoji(Name: "\u26a0\ufe0f"), // 'WARNING SIGN' (U+26A0)
URL: BuildInfo.IssuesUrl,
IsDisabled: BuildInfo.IsDirty
);
return ResultExtensions.FromError(await _feedback.SendContextualEmbedResultAsync(embed,
new FeedbackMessageOptions(MessageComponents: new[]
{
new ActionRowComponent(new[] { issuesButton })
}), ct)
);
}
}

View file

@ -0,0 +1,38 @@
using JetBrains.Annotations;
using Microsoft.Extensions.Logging;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Services;
using Remora.Results;
using TeamOctolings.Octobot.Extensions;
namespace TeamOctolings.Octobot.Commands.Events;
/// <summary>
/// Handles error logging for slash commands that couldn't be successfully prepared.
/// </summary>
[UsedImplicitly]
public class LoggingPreparationErrorEvent : IPreparationErrorEvent
{
private readonly ILogger<LoggingPreparationErrorEvent> _logger;
public LoggingPreparationErrorEvent(ILogger<LoggingPreparationErrorEvent> logger)
{
_logger = logger;
}
/// <summary>
/// Logs a warning using the injected <see cref="ILogger" /> if the <paramref name="preparationResult" /> has not
/// succeeded.
/// </summary>
/// <param name="context">The context of the slash command. Unused.</param>
/// <param name="preparationResult">The result whose success is checked.</param>
/// <param name="ct">The cancellation token for this operation. Unused.</param>
/// <returns>A result which has succeeded.</returns>
public Task<Result> PreparationFailed(
IOperationContext context, IResult preparationResult, CancellationToken ct = default)
{
_logger.LogResult(preparationResult, "Error in slash command preparation.");
return Task.FromResult(Result.Success);
}
}

View file

@ -0,0 +1,174 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using JetBrains.Annotations;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Commands.Attributes;
using Remora.Discord.Commands.Conditions;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Rest.Core;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace TeamOctolings.Octobot.Commands;
/// <summary>
/// Handles the command to kick members of a guild: /kick.
/// </summary>
[UsedImplicitly]
public class KickCommandGroup : CommandGroup
{
private readonly AccessControlService _access;
private readonly IDiscordRestChannelAPI _channelApi;
private readonly ICommandContext _context;
private readonly IFeedbackService _feedback;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi;
private readonly Utility _utility;
public KickCommandGroup(AccessControlService access, IDiscordRestChannelAPI channelApi, ICommandContext context,
IFeedbackService feedback, IDiscordRestGuildAPI guildApi, GuildDataService guildData,
IDiscordRestUserAPI userApi, Utility utility)
{
_access = access;
_channelApi = channelApi;
_context = context;
_feedback = feedback;
_guildApi = guildApi;
_guildData = guildData;
_userApi = userApi;
_utility = utility;
}
/// <summary>
/// A slash command that kicks a Discord member with the specified reason.
/// </summary>
/// <param name="target">The member to kick.</param>
/// <param name="reason">
/// The reason for this kick. Must be encoded with <see cref="StringExtensions.EncodeHeader" /> when passed to
/// <see cref="IDiscordRestGuildAPI.RemoveGuildMemberAsync" />.
/// </param>
/// <returns>
/// A feedback sending result which may or may not have succeeded. A successful result does not mean that the member
/// was kicked and vice-versa.
/// </returns>
[Command("kick", "кик")]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ManageMessages)]
[RequireBotDiscordPermissions(DiscordPermission.KickMembers)]
[Description("Kick member")]
[UsedImplicitly]
public async Task<Result> ExecuteKick(
[Description("Member to kick")] IUser target,
[Description("Kick reason")] [MaxLength(256)]
string reason)
{
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
// The bot's avatar is used when sending error messages
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return ResultExtensions.FromError(executorResult);
}
var guildResult = await _guildApi.GetGuildAsync(guildId, ct: CancellationToken);
if (!guildResult.IsDefined(out var guild))
{
return ResultExtensions.FromError(guildResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
var memberResult = await _guildApi.GetGuildMemberAsync(guildId, target.ID, CancellationToken);
if (!memberResult.IsSuccess)
{
var embed = new EmbedBuilder().WithSmallTitle(Messages.UserNotFoundShort, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: CancellationToken);
}
return await KickUserAsync(executor, target, reason, guild, channelId, data, bot, CancellationToken);
}
private async Task<Result> KickUserAsync(
IUser executor, IUser target, string reason, IGuild guild, Snowflake channelId, GuildData data, IUser bot,
CancellationToken ct = default)
{
var interactionResult
= await _access.CheckInteractionsAsync(guild.ID, executor.ID, target.ID, "Kick", ct);
if (!interactionResult.IsSuccess)
{
return ResultExtensions.FromError(interactionResult);
}
if (interactionResult.Entity is not null)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var dmChannelResult = await _userApi.CreateDMAsync(target.ID, ct);
if (dmChannelResult.IsDefined(out var dmChannel))
{
var dmEmbed = new EmbedBuilder().WithGuildTitle(guild)
.WithTitle(Messages.YouWereKicked)
.WithDescription(
MarkdownExtensions.BulletPoint(string.Format(Messages.DescriptionActionReason, reason)))
.WithActionFooter(executor)
.WithCurrentTimestamp()
.WithColour(ColorsList.Red)
.Build();
await _channelApi.CreateMessageWithEmbedResultAsync(dmChannel.ID, embedResult: dmEmbed, ct: ct);
}
var memberData = data.GetOrCreateMemberData(target.ID);
memberData.Kicked = true;
var kickResult = await _guildApi.RemoveGuildMemberAsync(
guild.ID, target.ID, $"({executor.GetTag()}) {reason}".EncodeHeader(),
ct);
if (!kickResult.IsSuccess)
{
memberData.Kicked = false;
return ResultExtensions.FromError(kickResult);
}
memberData.Roles.Clear();
var title = string.Format(Messages.UserKicked, target.GetTag());
var description = MarkdownExtensions.BulletPoint(string.Format(Messages.DescriptionActionReason, reason));
_utility.LogAction(
data.Settings, channelId, executor, title, description, target, ColorsList.Red, ct: ct);
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.UserKicked, target.GetTag()), target)
.WithColour(ColorsList.Green).Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
}

View file

@ -0,0 +1,388 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Text;
using JetBrains.Annotations;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Commands.Attributes;
using Remora.Discord.Commands.Conditions;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Services;
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;
using TeamOctolings.Octobot.Parsers;
using TeamOctolings.Octobot.Services;
using TeamOctolings.Octobot.Services.Update;
namespace TeamOctolings.Octobot.Commands;
/// <summary>
/// Handles commands related to mute management: /mute and /unmute.
/// </summary>
[UsedImplicitly]
public class MuteCommandGroup : CommandGroup
{
private readonly AccessControlService _access;
private readonly ICommandContext _context;
private readonly IFeedbackService _feedback;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi;
private readonly Utility _utility;
public MuteCommandGroup(AccessControlService access, ICommandContext context, IFeedbackService feedback,
IDiscordRestGuildAPI guildApi, GuildDataService guildData, IDiscordRestUserAPI userApi, Utility utility)
{
_access = access;
_context = context;
_feedback = feedback;
_guildApi = guildApi;
_guildData = guildData;
_userApi = userApi;
_utility = utility;
}
/// <summary>
/// A slash command that mutes a Discord member with the specified reason.
/// </summary>
/// <param name="target">The member to mute.</param>
/// <param name="stringDuration">The duration for this mute. The member will be automatically unmuted after this duration.</param>
/// <param name="reason">
/// The reason for this mute. Must be encoded with <see cref="StringExtensions.EncodeHeader" /> when passed to
/// <see cref="IDiscordRestGuildAPI.ModifyGuildMemberAsync" />.
/// </param>
/// <returns>
/// A feedback sending result which may or may not have succeeded. A successful result does not mean that the member
/// was muted and vice-versa.
/// </returns>
/// <seealso cref="ExecuteUnmute" />
[Command("mute", "мут")]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ManageMessages)]
[RequireBotDiscordPermissions(DiscordPermission.ModerateMembers)]
[Description("Mute member")]
[UsedImplicitly]
public async Task<Result> ExecuteMute(
[Description("Member to mute")] IUser target,
[Description("Mute reason")] [MaxLength(256)]
string reason,
[Description("Mute duration (e.g. 1h30m)")] [Option("duration")]
string stringDuration)
{
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
// The bot's avatar is used when sending error messages
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return ResultExtensions.FromError(executorResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
var memberResult = await _guildApi.GetGuildMemberAsync(guildId, target.ID, CancellationToken);
if (!memberResult.IsSuccess)
{
var embed = new EmbedBuilder().WithSmallTitle(Messages.UserNotFoundShort, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: CancellationToken);
}
var parseResult = TimeSpanParser.TryParse(stringDuration);
if (!parseResult.IsDefined(out var duration))
{
var failedEmbed = new EmbedBuilder()
.WithSmallTitle(Messages.InvalidTimeSpan, bot)
.WithDescription(Messages.TimeSpanExample)
.WithColour(ColorsList.Red)
.Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: CancellationToken);
}
return await MuteUserAsync(executor, target, reason, duration, guildId, data, channelId, bot,
CancellationToken);
}
private async Task<Result> MuteUserAsync(
IUser executor, IUser target, string reason, TimeSpan duration, Snowflake guildId, GuildData data,
Snowflake channelId, IUser bot, CancellationToken ct = default)
{
var interactionResult
= await _access.CheckInteractionsAsync(
guildId, executor.ID, target.ID, "Mute", ct);
if (!interactionResult.IsSuccess)
{
return ResultExtensions.FromError(interactionResult);
}
if (interactionResult.Entity is not null)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var until = DateTimeOffset.UtcNow.Add(duration); // >:)
var muteMethodResult =
await SelectMuteMethodAsync(executor, target, reason, duration, guildId, data, bot, until, ct);
if (!muteMethodResult.IsSuccess)
{
return ResultExtensions.FromError(muteMethodResult);
}
var title = string.Format(Messages.UserMuted, target.GetTag());
var description = new StringBuilder()
.AppendBulletPointLine(string.Format(Messages.DescriptionActionReason, reason))
.AppendBulletPoint(string.Format(
Messages.DescriptionActionExpiresAt, Markdown.Timestamp(until))).ToString();
_utility.LogAction(
data.Settings, channelId, executor, title, description, target, ColorsList.Red, ct: ct);
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.UserMuted, target.GetTag()), target)
.WithColour(ColorsList.Green).Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
private async Task<Result> SelectMuteMethodAsync(
IUser executor, IUser target, string reason, TimeSpan duration, Snowflake guildId, GuildData data,
IUser bot, DateTimeOffset until, CancellationToken ct)
{
var muteRole = GuildSettings.MuteRole.Get(data.Settings);
if (muteRole.Empty())
{
var timeoutResult = await TimeoutUserAsync(executor, target, reason, duration, guildId, bot, until, ct);
return timeoutResult;
}
var muteRoleResult = await RoleMuteUserAsync(executor, target, reason, guildId, data, until, muteRole, ct);
return muteRoleResult;
}
private async Task<Result> RoleMuteUserAsync(
IUser executor, IUser target, string reason, Snowflake guildId, GuildData data,
DateTimeOffset until, Snowflake muteRole, CancellationToken ct)
{
var assignRoles = new List<Snowflake> { muteRole };
var memberData = data.GetOrCreateMemberData(target.ID);
if (!GuildSettings.RemoveRolesOnMute.Get(data.Settings))
{
assignRoles.AddRange(memberData.Roles.ConvertAll(r => r.ToSnowflake()));
}
var muteResult = await _guildApi.ModifyGuildMemberAsync(
guildId, target.ID, roles: assignRoles,
reason: $"({executor.GetTag()}) {reason}".EncodeHeader(), ct: ct);
if (muteResult.IsSuccess)
{
memberData.MutedUntil = until;
}
return muteResult;
}
private async Task<Result> TimeoutUserAsync(
IUser executor, IUser target, string reason, TimeSpan duration, Snowflake guildId,
IUser bot, DateTimeOffset until, CancellationToken ct)
{
if (duration.TotalDays >= 28)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.BotCannotMuteTarget, bot)
.WithDescription(Messages.DurationRequiredForTimeOuts)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var muteResult = await _guildApi.ModifyGuildMemberAsync(
guildId, target.ID, reason: $"({executor.GetTag()}) {reason}".EncodeHeader(),
communicationDisabledUntil: until, ct: ct);
return muteResult;
}
/// <summary>
/// A slash command that unmutes a Discord member with the specified reason.
/// </summary>
/// <param name="target">The member to unmute.</param>
/// <param name="reason">
/// The reason for this unmute. Must be encoded with <see cref="StringExtensions.EncodeHeader" /> when passed to
/// <see cref="IDiscordRestGuildAPI.ModifyGuildMemberAsync" />.
/// </param>
/// <returns>
/// A feedback sending result which may or may not have succeeded. A successful result does not mean that the member
/// was unmuted and vice-versa.
/// </returns>
/// <seealso cref="ExecuteMute" />
/// <seealso cref="MemberUpdateService.TickMemberDataAsync" />
[Command("unmute", "размут")]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ManageMessages)]
[RequireBotDiscordPermissions(DiscordPermission.ModerateMembers)]
[Description("Unmute member")]
[UsedImplicitly]
public async Task<Result> ExecuteUnmute(
[Description("Member to unmute")] IUser target,
[Description("Unmute reason")] [MaxLength(256)]
string reason)
{
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
// The bot's avatar is used when sending error messages
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
// Needed to get the tag and avatar
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return ResultExtensions.FromError(executorResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
var memberResult = await _guildApi.GetGuildMemberAsync(guildId, target.ID, CancellationToken);
if (!memberResult.IsSuccess)
{
var embed = new EmbedBuilder().WithSmallTitle(Messages.UserNotFoundShort, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: CancellationToken);
}
return await RemoveMuteAsync(executor, target, reason, guildId, data, channelId, bot, CancellationToken);
}
private async Task<Result> RemoveMuteAsync(
IUser executor, IUser target, string reason, Snowflake guildId, GuildData data, Snowflake channelId,
IUser bot, CancellationToken ct = default)
{
var interactionResult
= await _access.CheckInteractionsAsync(
guildId, executor.ID, target.ID, "Unmute", ct);
if (!interactionResult.IsSuccess)
{
return ResultExtensions.FromError(interactionResult);
}
if (interactionResult.Entity is not null)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var guildMemberResult = await _guildApi.GetGuildMemberAsync(guildId, target.ID, ct);
DateTimeOffset? communicationDisabledUntil = null;
if (guildMemberResult.IsDefined(out var guildMember))
{
communicationDisabledUntil = guildMember.CommunicationDisabledUntil.OrDefault(null);
}
var memberData = data.GetOrCreateMemberData(target.ID);
var wasMuted = memberData.MutedUntil is not null || communicationDisabledUntil is not null;
if (!wasMuted)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.UserNotMuted, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var removeMuteRoleAsync =
await RemoveMuteRoleAsync(executor, target, reason, guildId, memberData, CancellationToken);
if (!removeMuteRoleAsync.IsSuccess)
{
return ResultExtensions.FromError(removeMuteRoleAsync);
}
var removeTimeoutResult =
await RemoveTimeoutAsync(executor, target, reason, guildId, communicationDisabledUntil, CancellationToken);
if (!removeTimeoutResult.IsSuccess)
{
return ResultExtensions.FromError(removeTimeoutResult);
}
var title = string.Format(Messages.UserUnmuted, target.GetTag());
var description = MarkdownExtensions.BulletPoint(string.Format(Messages.DescriptionActionReason, reason));
_utility.LogAction(
data.Settings, channelId, executor, title, description, target, ColorsList.Green, ct: ct);
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.UserUnmuted, target.GetTag()), target)
.WithColour(ColorsList.Green).Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
private async Task<Result> RemoveMuteRoleAsync(
IUser executor, IUser target, string reason, Snowflake guildId, MemberData memberData,
CancellationToken ct = default)
{
if (memberData.MutedUntil is null)
{
return Result.Success;
}
var unmuteResult = await _guildApi.ModifyGuildMemberAsync(
guildId, target.ID, roles: memberData.Roles.ConvertAll(r => r.ToSnowflake()),
reason: $"({executor.GetTag()}) {reason}".EncodeHeader(), ct: ct);
if (unmuteResult.IsSuccess)
{
memberData.MutedUntil = null;
}
return unmuteResult;
}
private async Task<Result> RemoveTimeoutAsync(
IUser executor, IUser target, string reason, Snowflake guildId, DateTimeOffset? communicationDisabledUntil,
CancellationToken ct = default)
{
if (communicationDisabledUntil is null)
{
return Result.Success;
}
var unmuteResult = await _guildApi.ModifyGuildMemberAsync(
guildId, target.ID, reason: $"({executor.GetTag()}) {reason}".EncodeHeader(),
communicationDisabledUntil: null, ct: ct);
return unmuteResult;
}
}

View file

@ -0,0 +1,102 @@
using System.ComponentModel;
using JetBrains.Annotations;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Commands.Attributes;
using Remora.Discord.Commands.Conditions;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway;
using Remora.Rest.Core;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace TeamOctolings.Octobot.Commands;
/// <summary>
/// Handles the command to get the time taken for the gateway to respond to the last heartbeat: /ping
/// </summary>
[UsedImplicitly]
public class PingCommandGroup : CommandGroup
{
private readonly IDiscordRestChannelAPI _channelApi;
private readonly DiscordGatewayClient _client;
private readonly ICommandContext _context;
private readonly IFeedbackService _feedback;
private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi;
public PingCommandGroup(
IDiscordRestChannelAPI channelApi, ICommandContext context, DiscordGatewayClient client,
GuildDataService guildData, IFeedbackService feedback, IDiscordRestUserAPI userApi)
{
_channelApi = channelApi;
_context = context;
_client = client;
_guildData = guildData;
_feedback = feedback;
_userApi = userApi;
}
/// <summary>
/// A slash command that shows time taken for the gateway to respond to the last heartbeat.
/// </summary>
/// <returns>
/// A feedback sending result which may or may not have succeeded.
/// </returns>
[Command("ping", "пинг")]
[Description("Get bot latency")]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[UsedImplicitly]
public async Task<Result> ExecutePingAsync()
{
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out _))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var cfg = await _guildData.GetSettings(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(cfg);
return await SendLatencyAsync(channelId, bot, CancellationToken);
}
private async Task<Result> SendLatencyAsync(
Snowflake channelId, IUser bot, CancellationToken ct = default)
{
var latency = _client.Latency.TotalMilliseconds;
if (latency is 0)
{
// No heartbeat has occurred, estimate latency from local time and "Octobot is thinking..." message
var lastMessageResult = await _channelApi.GetChannelMessagesAsync(
channelId, limit: 1, ct: ct);
if (!lastMessageResult.IsDefined(out var lastMessage))
{
return ResultExtensions.FromError(lastMessageResult);
}
latency = DateTimeOffset.UtcNow.Subtract(lastMessage.Single().Timestamp).TotalMilliseconds;
}
var embed = new EmbedBuilder().WithSmallTitle(bot.GetTag(), bot)
.WithTitle($"Generic{Random.Shared.Next(1, 4)}".Localized())
.WithDescription($"{latency:F0}{Messages.Milliseconds}")
.WithColour(latency < 250 ? ColorsList.Green : latency < 500 ? ColorsList.Yellow : ColorsList.Red)
.WithCurrentTimestamp()
.Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
}

View file

@ -0,0 +1,382 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Text;
using JetBrains.Annotations;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Commands.Attributes;
using Remora.Discord.Commands.Conditions;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Services;
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;
using TeamOctolings.Octobot.Parsers;
using TeamOctolings.Octobot.Services;
namespace TeamOctolings.Octobot.Commands;
/// <summary>
/// Handles commands to manage reminders: /remind, /listremind, /delremind
/// </summary>
[UsedImplicitly]
public class RemindCommandGroup : CommandGroup
{
private readonly IInteractionCommandContext _context;
private readonly IFeedbackService _feedback;
private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi;
private readonly IDiscordRestInteractionAPI _interactionApi;
public RemindCommandGroup(
IInteractionCommandContext context, GuildDataService guildData, IFeedbackService feedback,
IDiscordRestUserAPI userApi, IDiscordRestInteractionAPI interactionApi)
{
_context = context;
_guildData = guildData;
_feedback = feedback;
_userApi = userApi;
_interactionApi = interactionApi;
}
/// <summary>
/// A slash command that lists reminders of the user that called it.
/// </summary>
/// <returns>A feedback sending result which may or may not have succeeded.</returns>
[Command("listremind")]
[Description("List your reminders")]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[UsedImplicitly]
public async Task<Result> ExecuteListReminderAsync()
{
if (!_context.TryGetContextIDs(out var guildId, out _, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return ResultExtensions.FromError(executorResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
return await ListRemindersAsync(data.GetOrCreateMemberData(executorId), guildId, executor, bot, CancellationToken);
}
private Task<Result> ListRemindersAsync(MemberData data, Snowflake guildId, IUser executor, IUser bot, CancellationToken ct)
{
if (data.Reminders.Count == 0)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.NoRemindersFound, bot)
.WithColour(ColorsList.Red)
.Build();
return _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var builder = new StringBuilder();
for (var i = 0; i < data.Reminders.Count; i++)
{
var reminder = data.Reminders[i];
builder.AppendBulletPointLine(string.Format(Messages.ReminderPosition, Markdown.InlineCode((i + 1).ToString())))
.AppendSubBulletPointLine(string.Format(Messages.ReminderText, Markdown.InlineCode(reminder.Text)))
.AppendSubBulletPointLine(string.Format(Messages.ReminderTime, Markdown.Timestamp(reminder.At)))
.AppendSubBulletPointLine(string.Format(Messages.DescriptionActionJumpToMessage, $"https://discord.com/channels/{guildId.Value}/{reminder.ChannelId}/{reminder.MessageId}"));
}
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.ReminderList, executor.GetTag()), executor)
.WithDescription(builder.ToString())
.WithColour(ColorsList.Cyan)
.Build();
return _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
/// <summary>
/// A slash command that schedules a reminder with the specified text.
/// </summary>
/// <param name="timeSpanString">The period of time which must pass before the reminder will be sent.</param>
/// <param name="text">The text of the reminder.</param>
/// <returns>A feedback sending result which may or may not have succeeded.</returns>
[Command("remind")]
[Description("Create a reminder")]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[UsedImplicitly]
public async Task<Result> ExecuteReminderAsync(
[Description("After what period of time mention the reminder (e.g. 1h30m)")]
[Option("in")]
string timeSpanString,
[Description("Reminder text")] [MaxLength(512)]
string text)
{
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return ResultExtensions.FromError(executorResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
var parseResult = TimeSpanParser.TryParse(timeSpanString);
if (!parseResult.IsDefined(out var timeSpan))
{
var failedEmbed = new EmbedBuilder()
.WithSmallTitle(Messages.InvalidTimeSpan, bot)
.WithDescription(Messages.TimeSpanExample)
.WithColour(ColorsList.Red)
.Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: CancellationToken);
}
return await AddReminderAsync(timeSpan, text, data, channelId, executor, CancellationToken);
}
private async Task<Result> AddReminderAsync(TimeSpan timeSpan, string text, GuildData data,
Snowflake channelId, IUser executor, CancellationToken ct = default)
{
var memberData = data.GetOrCreateMemberData(executor.ID);
var remindAt = DateTimeOffset.UtcNow.Add(timeSpan);
var responseResult = await _interactionApi.GetOriginalInteractionResponseAsync(_context.Interaction.ApplicationID, _context.Interaction.Token, ct);
if (!responseResult.IsDefined(out var response))
{
return (Result)responseResult;
}
memberData.Reminders.Add(
new Reminder
{
At = remindAt,
ChannelId = channelId.Value,
Text = text,
MessageId = response.ID.Value
});
var builder = new StringBuilder()
.AppendBulletPointLine(string.Format(Messages.ReminderText, Markdown.InlineCode(text)))
.AppendBulletPoint(string.Format(Messages.ReminderTime, Markdown.Timestamp(remindAt)));
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.ReminderCreated, executor.GetTag()), executor)
.WithDescription(builder.ToString())
.WithColour(ColorsList.Green)
.WithFooter(string.Format(Messages.ReminderPosition, memberData.Reminders.Count))
.Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
public enum Parameters
{
[UsedImplicitly] Time,
[UsedImplicitly] Text
}
/// <summary>
/// A slash command that edits a scheduled reminder using the specified text or time.
/// </summary>
/// <param name="position">The list position of the reminder to edit.</param>
/// <param name="parameter">The reminder's parameter to edit.</param>
/// <param name="value">The new value for the reminder as a text or time.</param>
/// <returns>A feedback sending result which may or may not have succeeded.</returns>
[Command("editremind")]
[Description("Edit a reminder")]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[UsedImplicitly]
public async Task<Result> ExecuteEditReminderAsync(
[Description("Position in list")] [MinValue(1)]
int position,
[Description("Parameter to edit")] Parameters parameter,
[Description("Parameter's new value")] string value)
{
if (!_context.TryGetContextIDs(out var guildId, out _, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return ResultExtensions.FromError(executorResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
var memberData = data.GetOrCreateMemberData(executor.ID);
if (parameter is Parameters.Time)
{
return await EditReminderTimeAsync(position - 1, value, memberData, bot, executor, CancellationToken);
}
return await EditReminderTextAsync(position - 1, value, memberData, bot, executor, CancellationToken);
}
private async Task<Result> EditReminderTimeAsync(int index, string value, MemberData data,
IUser bot, IUser executor, CancellationToken ct = default)
{
if (index >= data.Reminders.Count)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.InvalidReminderPosition, bot)
.WithColour(ColorsList.Red)
.Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var parseResult = TimeSpanParser.TryParse(value);
if (!parseResult.IsDefined(out var timeSpan))
{
var failedEmbed = new EmbedBuilder()
.WithSmallTitle(Messages.InvalidTimeSpan, bot)
.WithDescription(Messages.TimeSpanExample)
.WithColour(ColorsList.Red)
.Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var oldReminder = data.Reminders[index];
var remindAt = DateTimeOffset.UtcNow.Add(timeSpan);
data.Reminders.Add(oldReminder with { At = remindAt });
data.Reminders.RemoveAt(index);
var builder = new StringBuilder()
.AppendBulletPointLine(string.Format(Messages.ReminderText, Markdown.InlineCode(oldReminder.Text)))
.AppendBulletPoint(string.Format(Messages.ReminderTime, Markdown.Timestamp(remindAt)));
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.ReminderEdited, executor.GetTag()), executor)
.WithDescription(builder.ToString())
.WithColour(ColorsList.Cyan)
.WithFooter(string.Format(Messages.ReminderPosition, data.Reminders.Count))
.Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
private async Task<Result> EditReminderTextAsync(int index, string value, MemberData data,
IUser bot, IUser executor, CancellationToken ct = default)
{
if (index >= data.Reminders.Count)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.InvalidReminderPosition, bot)
.WithColour(ColorsList.Red)
.Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var oldReminder = data.Reminders[index];
data.Reminders.Add(oldReminder with { Text = value });
data.Reminders.RemoveAt(index);
var builder = new StringBuilder()
.AppendBulletPointLine(string.Format(Messages.ReminderText, Markdown.InlineCode(value)))
.AppendBulletPoint(string.Format(Messages.ReminderTime, Markdown.Timestamp(oldReminder.At)));
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.ReminderEdited, executor.GetTag()), executor)
.WithDescription(builder.ToString())
.WithColour(ColorsList.Cyan)
.WithFooter(string.Format(Messages.ReminderPosition, data.Reminders.Count))
.Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
/// <summary>
/// A slash command that deletes a reminder using its list position.
/// </summary>
/// <param name="position">The list position of the reminder to delete.</param>
/// <returns>A feedback sending result which may or may not have succeeded.</returns>
[Command("delremind")]
[Description("Delete one of your reminders")]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[UsedImplicitly]
public async Task<Result> ExecuteDeleteReminderAsync(
[Description("Position in list")] [MinValue(1)]
int position)
{
if (!_context.TryGetContextIDs(out var guildId, out _, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
return await DeleteReminderAsync(data.GetOrCreateMemberData(executorId), position - 1, bot, CancellationToken);
}
private Task<Result> DeleteReminderAsync(MemberData data, int index, IUser bot,
CancellationToken ct)
{
if (index >= data.Reminders.Count)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.InvalidReminderPosition, bot)
.WithColour(ColorsList.Red)
.Build();
return _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var reminder = data.Reminders[index];
var description = new StringBuilder()
.AppendBulletPointLine(string.Format(Messages.ReminderText, Markdown.InlineCode(reminder.Text)))
.AppendBulletPointLine(string.Format(Messages.ReminderTime, Markdown.Timestamp(reminder.At)));
data.Reminders.RemoveAt(index);
var embed = new EmbedBuilder().WithSmallTitle(Messages.ReminderDeleted, bot)
.WithDescription(description.ToString())
.WithColour(ColorsList.Green)
.Build();
return _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
}

View file

@ -0,0 +1,309 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Text;
using System.Text.Json.Nodes;
using JetBrains.Annotations;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Commands.Attributes;
using Remora.Discord.Commands.Conditions;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Data.Options;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace TeamOctolings.Octobot.Commands;
/// <summary>
/// Handles the commands to list and modify per-guild settings: /settings and /settings list.
/// </summary>
[UsedImplicitly]
public class SettingsCommandGroup : CommandGroup
{
/// <summary>
/// Represents all options as an array of objects implementing <see cref="IGuildOption" />.
/// </summary>
/// <remarks>
/// WARNING: If you update this array in any way, you must also update <see cref="AllOptionsEnum" /> and make sure
/// that the orders match.
/// </remarks>
private static readonly IGuildOption[] AllOptions =
[
GuildSettings.Language,
GuildSettings.WelcomeMessage,
GuildSettings.LeaveMessage,
GuildSettings.ReceiveStartupMessages,
GuildSettings.RemoveRolesOnMute,
GuildSettings.ReturnRolesOnRejoin,
GuildSettings.AutoStartEvents,
GuildSettings.RenameHoistedUsers,
GuildSettings.PublicFeedbackChannel,
GuildSettings.PrivateFeedbackChannel,
GuildSettings.WelcomeMessagesChannel,
GuildSettings.EventNotificationChannel,
GuildSettings.DefaultRole,
GuildSettings.MuteRole,
GuildSettings.ModeratorRole,
GuildSettings.EventNotificationRole,
GuildSettings.EventEarlyNotificationOffset
];
private readonly ICommandContext _context;
private readonly IFeedbackService _feedback;
private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi;
private readonly Utility _utility;
public SettingsCommandGroup(
ICommandContext context, GuildDataService guildData,
IFeedbackService feedback, IDiscordRestUserAPI userApi, Utility utility)
{
_context = context;
_guildData = guildData;
_feedback = feedback;
_userApi = userApi;
_utility = utility;
}
/// <summary>
/// A slash command that sends a page from the list of current GuildSettings.
/// </summary>
/// <param name="page">The number of the page to send.</param>
/// <returns>
/// A feedback sending result which may or may not have succeeded.
/// </returns>
[Command("listsettings")]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageGuild)]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ManageGuild)]
[Description("Shows settings list for this server")]
[UsedImplicitly]
public async Task<Result> ExecuteListSettingsAsync(
[Description("Settings list page")] [MinValue(1)]
int page)
{
if (!_context.TryGetContextIDs(out var guildId, out _, out _))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var cfg = await _guildData.GetSettings(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(cfg);
return await SendSettingsListAsync(cfg, bot, page, CancellationToken);
}
private Task<Result> SendSettingsListAsync(JsonNode cfg, IUser bot, int page,
CancellationToken ct = default)
{
var description = new StringBuilder();
var footer = new StringBuilder();
const int optionsPerPage = 10;
var totalPages = (AllOptions.Length + optionsPerPage - 1) / optionsPerPage;
var lastOptionOnPage = Math.Min(optionsPerPage * page, AllOptions.Length);
var firstOptionOnPage = optionsPerPage * page - optionsPerPage;
if (firstOptionOnPage >= AllOptions.Length)
{
var errorEmbed = new EmbedBuilder().WithSmallTitle(Messages.PageNotFound, bot)
.WithDescription(string.Format(Messages.PagesAllowed, Markdown.Bold(totalPages.ToString())))
.WithColour(ColorsList.Red)
.Build();
return _feedback.SendContextualEmbedResultAsync(errorEmbed, ct: ct);
}
footer.Append($"{Messages.Page} {page}/{totalPages} ");
for (var i = 0; i < totalPages; i++)
{
footer.Append(i + 1 == page ? "●" : "○");
}
for (var i = firstOptionOnPage; i < lastOptionOnPage; i++)
{
var optionName = AllOptions[i].Name;
var optionValue = AllOptions[i].Display(cfg);
description.AppendBulletPointLine($"Settings{optionName}".Localized())
.AppendSubBulletPoint(Markdown.InlineCode(optionName))
.Append(": ").AppendLine(optionValue);
}
var embed = new EmbedBuilder().WithSmallTitle(Messages.SettingsListTitle, bot)
.WithDescription(description.ToString())
.WithColour(ColorsList.Default)
.WithFooter(footer.ToString())
.Build();
return _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
/// <summary>
/// A slash command that modifies per-guild GuildSettings.
/// </summary>
/// <param name="setting">The setting to modify.</param>
/// <param name="value">The new value of the setting.</param>
/// <returns>A feedback sending result which may or may not have succeeded.</returns>
[Command("editsettings")]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageGuild)]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ManageGuild)]
[Description("Change settings for this server")]
[UsedImplicitly]
public async Task<Result> ExecuteEditSettingsAsync(
[Description("The setting whose value you want to change")]
AllOptionsEnum setting,
[Description("Setting value")] [MaxLength(512)]
string value)
{
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return ResultExtensions.FromError(executorResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
return await EditSettingAsync(AllOptions[(int)setting], value, data, channelId, executor, bot,
CancellationToken);
}
private async Task<Result> EditSettingAsync(
IGuildOption option, string value, GuildData data, Snowflake channelId, IUser executor, IUser bot,
CancellationToken ct = default)
{
var setResult = option.Set(data.Settings, value);
if (!setResult.IsSuccess)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.SettingNotChanged, bot)
.WithDescription(setResult.Error.Message)
.WithColour(ColorsList.Red)
.Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var builder = new StringBuilder();
builder.Append(Markdown.InlineCode(option.Name))
.Append($" {Messages.SettingIsNow} ")
.Append(option.Display(data.Settings));
var title = Messages.SettingSuccessfullyChanged;
var description = builder.ToString();
_utility.LogAction(
data.Settings, channelId, executor, title, description, bot, ColorsList.Magenta, false, ct);
var embed = new EmbedBuilder().WithSmallTitle(title, bot)
.WithDescription(description)
.WithColour(ColorsList.Green)
.Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
/// <summary>
/// A slash command that resets per-guild GuildSettings.
/// </summary>
/// <param name="setting">The setting to reset.</param>
/// <returns>A feedback sending result which may have succeeded.</returns>
[Command("resetsettings")]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageGuild)]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ManageGuild)]
[Description("Reset settings for this guild")]
[UsedImplicitly]
public async Task<Result> ExecuteResetSettingsAsync(
[Description("Setting to reset")] AllOptionsEnum? setting = null)
{
if (!_context.TryGetContextIDs(out var guildId, out _, out _))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var cfg = await _guildData.GetSettings(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(cfg);
if (setting is not null)
{
return await ResetSingleSettingAsync(cfg, bot, AllOptions[(int)setting], CancellationToken);
}
return await ResetAllSettingsAsync(cfg, bot, CancellationToken);
}
private async Task<Result> ResetSingleSettingAsync(JsonNode cfg, IUser bot,
IGuildOption option, CancellationToken ct = default)
{
var resetResult = option.Reset(cfg);
if (!resetResult.IsSuccess)
{
return ResultExtensions.FromError(resetResult);
}
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.SingleSettingReset, option.Name), bot)
.WithColour(ColorsList.Green)
.Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
private async Task<Result> ResetAllSettingsAsync(JsonNode cfg, IUser bot,
CancellationToken ct = default)
{
var failedResults = new List<Result>();
foreach (var resetResult in AllOptions.Select(option => option.Reset(cfg)))
{
failedResults.AddIfFailed(resetResult);
}
if (failedResults.Count is not 0)
{
return failedResults.AggregateErrors();
}
var embed = new EmbedBuilder().WithSmallTitle(Messages.AllSettingsReset, bot)
.WithColour(ColorsList.Green)
.Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
}

View file

@ -0,0 +1,561 @@
using System.ComponentModel;
using System.Drawing;
using System.Text;
using JetBrains.Annotations;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Commands.Attributes;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Services;
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;
using TeamOctolings.Octobot.Parsers;
using TeamOctolings.Octobot.Services;
namespace TeamOctolings.Octobot.Commands;
/// <summary>
/// Handles tool commands: /userinfo, /guildinfo, /random, /timestamp, /8ball.
/// </summary>
[UsedImplicitly]
public class ToolsCommandGroup : CommandGroup
{
private readonly ICommandContext _context;
private readonly IFeedbackService _feedback;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi;
public ToolsCommandGroup(
ICommandContext context, IFeedbackService feedback,
GuildDataService guildData, IDiscordRestGuildAPI guildApi,
IDiscordRestUserAPI userApi)
{
_context = context;
_guildData = guildData;
_feedback = feedback;
_guildApi = guildApi;
_userApi = userApi;
}
/// <summary>
/// A slash command that shows information about user.
/// </summary>
/// <remarks>
/// Information in the output:
/// <list type="bullet">
/// <item>Display name</item>
/// <item>Discord user since</item>
/// <item>Guild nickname</item>
/// <item>Guild member since</item>
/// <item>Nitro booster since</item>
/// <item>Guild roles</item>
/// <item>Active mute information</item>
/// <item>Active ban information</item>
/// <item>Is on guild status</item>
/// </list>
/// </remarks>
/// <param name="target">The user to show info about.</param>
/// <returns>
/// A feedback sending result which may or may not have succeeded.
/// </returns>
[Command("userinfo")]
[DiscordDefaultDMPermission(false)]
[Description("Shows info about user")]
[UsedImplicitly]
public async Task<Result> ExecuteUserInfoAsync(
[Description("User to show info about")]
IUser? target = null)
{
if (!_context.TryGetContextIDs(out var guildId, out _, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return ResultExtensions.FromError(executorResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
return await ShowUserInfoAsync(target ?? executor, bot, data, guildId, CancellationToken);
}
private async Task<Result> ShowUserInfoAsync(
IUser target, IUser bot, GuildData data, Snowflake guildId, CancellationToken ct = default)
{
var builder = new StringBuilder().AppendLine($"### <@{target.ID}>");
if (target.GlobalName.IsDefined(out var globalName))
{
builder.AppendBulletPointLine(Messages.UserInfoDisplayName)
.AppendLine(Markdown.InlineCode(globalName));
}
builder.AppendBulletPointLine(Messages.UserInfoDiscordUserSince)
.AppendLine(Markdown.Timestamp(target.ID.Timestamp));
var memberData = data.GetOrCreateMemberData(target.ID);
var embedColor = ColorsList.Cyan;
var guildMemberResult = await _guildApi.GetGuildMemberAsync(guildId, target.ID, ct);
DateTimeOffset? communicationDisabledUntil = null;
if (guildMemberResult.IsDefined(out var guildMember))
{
communicationDisabledUntil = guildMember.CommunicationDisabledUntil.OrDefault(null);
embedColor = AppendGuildInformation(embedColor, guildMember, builder);
}
var wasMuted = (memberData.MutedUntil is not null && DateTimeOffset.UtcNow <= memberData.MutedUntil) ||
communicationDisabledUntil is not null;
var wasBanned = memberData.BannedUntil is not null;
var wasKicked = memberData.Kicked;
if (wasMuted || wasBanned || wasKicked)
{
builder.Append("### ")
.AppendLine(Markdown.Bold(Messages.UserInfoPunishments));
embedColor = AppendPunishmentsInformation(wasMuted, wasKicked, wasBanned, memberData,
builder, embedColor, communicationDisabledUntil);
}
if (!guildMemberResult.IsSuccess && !wasBanned)
{
builder.Append("### ")
.AppendLine(Markdown.Bold(Messages.UserInfoNotOnGuild));
embedColor = ColorsList.Default;
}
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.InformationAbout, target.GetTag()), bot)
.WithDescription(builder.ToString())
.WithColour(embedColor)
.WithLargeUserAvatar(target)
.WithFooter($"ID: {target.ID.ToString()}")
.Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
private static Color AppendPunishmentsInformation(bool wasMuted, bool wasKicked, bool wasBanned,
MemberData memberData, StringBuilder builder, Color embedColor, DateTimeOffset? communicationDisabledUntil)
{
if (wasMuted)
{
AppendMuteInformation(memberData, communicationDisabledUntil, builder);
embedColor = ColorsList.Red;
}
if (wasKicked)
{
builder.AppendBulletPointLine(Messages.UserInfoKicked);
}
if (wasBanned)
{
AppendBanInformation(memberData, builder);
embedColor = ColorsList.Black;
}
return embedColor;
}
private static Color AppendGuildInformation(Color color, IGuildMember guildMember, StringBuilder builder)
{
if (guildMember.Nickname.IsDefined(out var nickname))
{
builder.AppendBulletPointLine(Messages.UserInfoGuildNickname)
.AppendLine(Markdown.InlineCode(nickname));
}
builder.AppendBulletPointLine(Messages.UserInfoGuildMemberSince)
.AppendLine(Markdown.Timestamp(guildMember.JoinedAt));
if (guildMember.PremiumSince.IsDefined(out var premiumSince))
{
builder.AppendBulletPointLine(Messages.UserInfoGuildMemberPremiumSince)
.AppendLine(Markdown.Timestamp(premiumSince.Value));
color = ColorsList.Magenta;
}
if (guildMember.Roles.Count > 0)
{
builder.AppendBulletPointLine(Messages.UserInfoGuildRoles);
for (var i = 0; i < guildMember.Roles.Count - 1; i++)
{
builder.Append($"<@&{guildMember.Roles[i]}>, ");
}
builder.AppendLine($"<@&{guildMember.Roles[^1]}>");
}
return color;
}
private static void AppendBanInformation(MemberData memberData, StringBuilder builder)
{
if (memberData.BannedUntil < DateTimeOffset.MaxValue)
{
builder.AppendBulletPointLine(Messages.UserInfoBanned)
.AppendSubBulletPointLine(string.Format(
Messages.DescriptionActionExpiresAt, Markdown.Timestamp(memberData.BannedUntil.Value)));
return;
}
builder.AppendBulletPointLine(Messages.UserInfoBannedPermanently);
}
private static void AppendMuteInformation(
MemberData memberData, DateTimeOffset? communicationDisabledUntil, StringBuilder builder)
{
builder.AppendBulletPointLine(Messages.UserInfoMuted);
if (memberData.MutedUntil is not null && DateTimeOffset.UtcNow <= memberData.MutedUntil)
{
builder.AppendSubBulletPointLine(Messages.UserInfoMutedByMuteRole)
.AppendSubBulletPointLine(string.Format(
Messages.DescriptionActionExpiresAt, Markdown.Timestamp(memberData.MutedUntil.Value)));
}
if (communicationDisabledUntil is not null)
{
builder.AppendSubBulletPointLine(Messages.UserInfoMutedByTimeout)
.AppendSubBulletPointLine(string.Format(
Messages.DescriptionActionExpiresAt, Markdown.Timestamp(communicationDisabledUntil.Value)));
}
}
/// <summary>
/// A slash command that shows guild information.
/// </summary>
/// <remarks>
/// Information in the output:
/// <list type="bullet">
/// <item>Guild description</item>
/// <item>Creation date</item>
/// <item>Guild's language</item>
/// <item>Guild's owner</item>
/// <item>Boost level</item>
/// <item>Boost count</item>
/// </list>
/// </remarks>
/// <returns>
/// A feedback sending result which may or may not have succeeded.
/// </returns>
[Command("guildinfo")]
[DiscordDefaultDMPermission(false)]
[Description("Shows info about current guild")]
[UsedImplicitly]
public async Task<Result> ExecuteGuildInfoAsync()
{
if (!_context.TryGetContextIDs(out var guildId, out _, out _))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var guildResult = await _guildApi.GetGuildAsync(guildId, ct: CancellationToken);
if (!guildResult.IsDefined(out var guild))
{
return ResultExtensions.FromError(guildResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
return await ShowGuildInfoAsync(bot, guild, CancellationToken);
}
private Task<Result> ShowGuildInfoAsync(IUser bot, IGuild guild, CancellationToken ct)
{
var description = new StringBuilder().AppendLine($"## {guild.Name}");
if (guild.Description is not null)
{
description.AppendBulletPointLine(Messages.GuildInfoDescription)
.AppendLine(Markdown.InlineCode(guild.Description));
}
description.AppendBulletPointLine(Messages.GuildInfoCreatedAt)
.AppendLine(Markdown.Timestamp(guild.ID.Timestamp))
.AppendBulletPointLine(Messages.GuildInfoOwner)
.AppendLine(Mention.User(guild.OwnerID));
var embedColor = ColorsList.Cyan;
if (guild.PremiumTier > PremiumTier.None)
{
description.Append("### ").AppendLine(Messages.GuildInfoServerBoost)
.AppendBulletPoint(Messages.GuildInfoBoostTier)
.Append(": ").AppendLine(Markdown.InlineCode(guild.PremiumTier.ToString()))
.AppendBulletPoint(Messages.GuildInfoBoostCount)
.Append(": ").AppendLine(Markdown.InlineCode(guild.PremiumSubscriptionCount.ToString()));
embedColor = ColorsList.Magenta;
}
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.InformationAbout, guild.Name), bot)
.WithDescription(description.ToString())
.WithColour(embedColor)
.WithLargeGuildIcon(guild)
.WithGuildBanner(guild)
.WithFooter($"ID: {guild.ID.ToString()}")
.Build();
return _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
/// <summary>
/// A slash command that generates a random number using maximum and minimum numbers.
/// </summary>
/// <param name="first">The first number used for randomization.</param>
/// <param name="second">The second number used for randomization. Default value: 0</param>
/// <returns>
/// A feedback sending result which may or may not have succeeded.
/// </returns>
[Command("random")]
[DiscordDefaultDMPermission(false)]
[Description("Generates a random number")]
[UsedImplicitly]
public async Task<Result> ExecuteRandomAsync(
[Description("First number")] long first,
[Description("Second number (Default: 0)")]
long? second = null)
{
if (!_context.TryGetContextIDs(out var guildId, out _, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return ResultExtensions.FromError(executorResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
return await SendRandomNumberAsync(first, second, executor, CancellationToken);
}
private Task<Result> SendRandomNumberAsync(long first, long? secondNullable,
IUser executor, CancellationToken ct)
{
const long secondDefault = 0;
var second = secondNullable ?? secondDefault;
var min = Math.Min(first, second);
var max = Math.Max(first, second);
var i = Random.Shared.NextInt64(min, max + 1);
var description = new StringBuilder().Append("# ").Append(i);
description.AppendLine().AppendBulletPoint(string.Format(
Messages.RandomMin, Markdown.InlineCode(min.ToString())));
if (secondNullable is null && first >= secondDefault)
{
description.Append(' ').Append(Messages.Default);
}
description.AppendLine().AppendBulletPoint(string.Format(
Messages.RandomMax, Markdown.InlineCode(max.ToString())));
if (secondNullable is null && first < secondDefault)
{
description.Append(' ').Append(Messages.Default);
}
var embedColor = ColorsList.Blue;
if (secondNullable is not null && min == max)
{
description.AppendLine().Append(Markdown.Italicise(Messages.RandomMinMaxSame));
embedColor = ColorsList.Red;
}
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.RandomTitle, executor.GetTag()), executor)
.WithDescription(description.ToString())
.WithColour(embedColor)
.Build();
return _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
private static readonly TimestampStyle[] AllStyles =
[
TimestampStyle.ShortDate,
TimestampStyle.LongDate,
TimestampStyle.ShortTime,
TimestampStyle.LongTime,
TimestampStyle.ShortDateTime,
TimestampStyle.LongDateTime,
TimestampStyle.RelativeTime
];
/// <summary>
/// A slash command that shows the current timestamp with an optional offset in all styles supported by Discord.
/// </summary>
/// <param name="stringOffset">The offset for the current timestamp.</param>
/// <returns>
/// A feedback sending result which may or may not have succeeded.
/// </returns>
[Command("timestamp")]
[DiscordDefaultDMPermission(false)]
[Description("Shows a timestamp in all styles")]
[UsedImplicitly]
public async Task<Result> ExecuteTimestampAsync(
[Description("Offset from current time")] [Option("offset")]
string? stringOffset = null)
{
if (!_context.TryGetContextIDs(out var guildId, out _, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return ResultExtensions.FromError(executorResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
if (stringOffset is null)
{
return await SendTimestampAsync(null, executor, CancellationToken);
}
var parseResult = TimeSpanParser.TryParse(stringOffset);
if (!parseResult.IsDefined(out var offset))
{
var failedEmbed = new EmbedBuilder()
.WithSmallTitle(Messages.InvalidTimeSpan, bot)
.WithDescription(Messages.TimeSpanExample)
.WithColour(ColorsList.Red)
.Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: CancellationToken);
}
return await SendTimestampAsync(offset, executor, CancellationToken);
}
private Task<Result> SendTimestampAsync(TimeSpan? offset, IUser executor, CancellationToken ct)
{
var timestamp = DateTimeOffset.UtcNow.Add(offset ?? TimeSpan.Zero).ToUnixTimeSeconds();
var description = new StringBuilder().Append("# ").AppendLine(timestamp.ToString());
if (offset is not null)
{
description.AppendLine(string.Format(
Messages.TimestampOffset, Markdown.InlineCode(offset.ToString() ?? string.Empty))).AppendLine();
}
foreach (var markdownTimestamp in AllStyles.Select(style => Markdown.Timestamp(timestamp, style)))
{
description.AppendBulletPoint(Markdown.InlineCode(markdownTimestamp))
.Append(" → ").AppendLine(markdownTimestamp);
}
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.TimestampTitle, executor.GetTag()), executor)
.WithDescription(description.ToString())
.WithColour(ColorsList.Blue)
.Build();
return _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
/// <summary>
/// A slash command that shows a random answer from the Magic 8-Ball.
/// </summary>
/// <param name="question">Unused input.</param>
/// <remarks>
/// The 8-Ball answers were taken from <a href="https://en.wikipedia.org/wiki/Magic_8_Ball#Possible_answers">Wikipedia</a>.
/// </remarks>
/// <returns>
/// A feedback sending result which may or may not have succeeded.
/// </returns>
[Command("8ball")]
[DiscordDefaultDMPermission(false)]
[Description("Ask the Magic 8-Ball a question")]
[UsedImplicitly]
public async Task<Result> ExecuteEightBallAsync(
// let the user think he's actually asking the ball a question
[Description("Question to ask")] string question)
{
if (!_context.TryGetContextIDs(out var guildId, out _, out _))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
return await AnswerEightBallAsync(bot, CancellationToken);
}
private static readonly string[] AnswerTypes =
[
"Positive", "Questionable", "Neutral", "Negative"
];
private Task<Result> AnswerEightBallAsync(IUser bot, CancellationToken ct)
{
var typeNumber = Random.Shared.Next(0, 4);
var embedColor = typeNumber switch
{
0 => ColorsList.Blue,
1 => ColorsList.Green,
2 => ColorsList.Yellow,
3 => ColorsList.Red,
_ => throw new ArgumentOutOfRangeException(null, nameof(typeNumber))
};
var answer = $"EightBall{AnswerTypes[typeNumber]}{Random.Shared.Next(1, 6)}".Localized();
var embed = new EmbedBuilder().WithSmallTitle(answer, bot)
.WithColour(embedColor)
.Build();
return _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
}

View file

@ -0,0 +1,47 @@
using System.Text.Json.Nodes;
using Remora.Rest.Core;
namespace TeamOctolings.Octobot.Data;
/// <summary>
/// Stores information about a guild. This information is not accessible via the Discord API.
/// </summary>
/// <remarks>This information is stored on disk as a JSON file.</remarks>
public sealed class GuildData
{
public readonly Dictionary<ulong, MemberData> MemberData;
public readonly string MemberDataPath;
public readonly Dictionary<ulong, ScheduledEventData> ScheduledEvents;
public readonly string ScheduledEventsPath;
public readonly JsonNode Settings;
public readonly string SettingsPath;
public readonly bool DataLoadFailed;
public GuildData(
JsonNode settings, string settingsPath,
Dictionary<ulong, ScheduledEventData> scheduledEvents, string scheduledEventsPath,
Dictionary<ulong, MemberData> memberData, string memberDataPath, bool dataLoadFailed)
{
Settings = settings;
SettingsPath = settingsPath;
ScheduledEvents = scheduledEvents;
ScheduledEventsPath = scheduledEventsPath;
MemberData = memberData;
MemberDataPath = memberDataPath;
DataLoadFailed = dataLoadFailed;
}
public MemberData GetOrCreateMemberData(Snowflake memberId)
{
if (MemberData.TryGetValue(memberId.Value, out var existing))
{
return existing;
}
var newData = new MemberData(memberId.Value);
MemberData.Add(memberId.Value, newData);
return newData;
}
}

View file

@ -0,0 +1,87 @@
using Remora.Discord.API.Abstractions.Objects;
using TeamOctolings.Octobot.Data.Options;
using TeamOctolings.Octobot.Responders;
namespace TeamOctolings.Octobot.Data;
/// <summary>
/// Contains all per-guild settings that can be set by a member
/// with <see cref="DiscordPermission.ManageGuild" /> using the /settings command
/// </summary>
public static class GuildSettings
{
public static readonly LanguageOption Language = new("Language", "en");
/// <summary>
/// Controls what message should be sent in <see cref="PublicFeedbackChannel" /> when a new member joins the guild.
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item>No message will be sent if set to "off", "disable" or "disabled".</item>
/// <item><see cref="Messages.DefaultWelcomeMessage" /> will be sent if set to "default" or "reset".</item>
/// </list>
/// </remarks>
/// <seealso cref="GuildMemberJoinedResponder" />
public static readonly GuildOption<string> WelcomeMessage = new("WelcomeMessage", "default");
/// <summary>
/// Controls what message should be sent in <see cref="PublicFeedbackChannel" /> when a member leaves the guild.
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item>No message will be sent if set to "off", "disable" or "disabled".</item>
/// <item><see cref="Messages.DefaultLeaveMessage" /> will be sent if set to "default" or "reset".</item>
/// </list>
/// </remarks>
/// <seealso cref="GuildMemberLeftResponder" />
public static readonly GuildOption<string> LeaveMessage = new("LeaveMessage", "default");
/// <summary>
/// Controls whether or not the <see cref="Messages.Ready" /> message should be sent
/// in <see cref="PrivateFeedbackChannel" /> on startup.
/// </summary>
/// <seealso cref="GuildLoadedResponder" />
public static readonly BoolOption ReceiveStartupMessages = new("ReceiveStartupMessages", false);
public static readonly BoolOption RemoveRolesOnMute = new("RemoveRolesOnMute", false);
/// <summary>
/// Controls whether or not a guild member's roles are returned if he/she leaves and then joins back.
/// </summary>
/// <remarks>Roles will not be returned if the member left the guild because of /ban or /kick.</remarks>
public static readonly BoolOption ReturnRolesOnRejoin = new("ReturnRolesOnRejoin", false);
public static readonly BoolOption AutoStartEvents = new("AutoStartEvents", false);
/// <summary>
/// Controls whether or not users who try to hoist themselves should be renamed.
/// </summary>
public static readonly BoolOption RenameHoistedUsers = new("RenameHoistedUsers", false);
/// <summary>
/// Controls what channel should all public messages be sent to.
/// </summary>
public static readonly SnowflakeOption PublicFeedbackChannel = new("PublicFeedbackChannel");
/// <summary>
/// Controls what channel should all private, moderator-only messages be sent to.
/// </summary>
public static readonly SnowflakeOption PrivateFeedbackChannel = new("PrivateFeedbackChannel");
/// <summary>
/// Controls what channel should welcome messages be sent to.
/// </summary>
public static readonly SnowflakeOption WelcomeMessagesChannel = new("WelcomeMessagesChannel");
public static readonly SnowflakeOption EventNotificationChannel = new("EventNotificationChannel");
public static readonly SnowflakeOption DefaultRole = new("DefaultRole");
public static readonly SnowflakeOption MuteRole = new("MuteRole");
public static readonly SnowflakeOption ModeratorRole = new("ModeratorRole");
public static readonly SnowflakeOption EventNotificationRole = new("EventNotificationRole");
/// <summary>
/// Controls the amount of time before a scheduled event to send a reminder in <see cref="EventNotificationChannel" />.
/// </summary>
public static readonly TimeSpanOption EventEarlyNotificationOffset = new(
"EventEarlyNotificationOffset", TimeSpan.Zero);
}

View file

@ -0,0 +1,23 @@
namespace TeamOctolings.Octobot.Data;
/// <summary>
/// Stores information about a member
/// </summary>
public sealed class MemberData
{
public MemberData(ulong id, List<Reminder>? reminders = null)
{
Id = id;
if (reminders is not null)
{
Reminders = reminders;
}
}
public ulong Id { get; }
public DateTimeOffset? BannedUntil { get; set; }
public DateTimeOffset? MutedUntil { get; set; }
public bool Kicked { get; set; }
public List<ulong> Roles { get; set; } = [];
public List<Reminder> Reminders { get; } = [];
}

View file

@ -0,0 +1,32 @@
using JetBrains.Annotations;
using TeamOctolings.Octobot.Commands;
namespace TeamOctolings.Octobot.Data.Options;
/// <summary>
/// Represents all options as enums.
/// </summary>
/// <remarks>
/// WARNING: This enum is order-dependent! It's values are used as indexes for
/// <see cref="SettingsCommandGroup.AllOptions" />.
/// </remarks>
public enum AllOptionsEnum
{
[UsedImplicitly] Language,
[UsedImplicitly] WelcomeMessage,
[UsedImplicitly] LeaveMessage,
[UsedImplicitly] ReceiveStartupMessages,
[UsedImplicitly] RemoveRolesOnMute,
[UsedImplicitly] ReturnRolesOnRejoin,
[UsedImplicitly] AutoStartEvents,
[UsedImplicitly] RenameHoistedUsers,
[UsedImplicitly] PublicFeedbackChannel,
[UsedImplicitly] PrivateFeedbackChannel,
[UsedImplicitly] WelcomeMessagesChannel,
[UsedImplicitly] EventNotificationChannel,
[UsedImplicitly] DefaultRole,
[UsedImplicitly] MuteRole,
[UsedImplicitly] ModeratorRole,
[UsedImplicitly] EventNotificationRole,
[UsedImplicitly] EventEarlyNotificationOffset
}

View file

@ -0,0 +1,41 @@
using System.Text.Json.Nodes;
using Remora.Results;
namespace TeamOctolings.Octobot.Data.Options;
public sealed class BoolOption : GuildOption<bool>
{
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 new ArgumentInvalidError(nameof(from), Messages.InvalidSettingValue);
}
settings[Name] = value;
return Result.Success;
}
private static bool TryParseBool(string from, out bool value)
{
value = false;
switch (from.ToLowerInvariant())
{
case "true" or "1" or "y" or "yes" or "д" or "да":
value = true;
return true;
case "false" or "0" or "n" or "no" or "н" or "не" or "нет" or "нъет":
value = false;
return true;
default:
return false;
}
}
}

View file

@ -0,0 +1,57 @@
using System.Text.Json.Nodes;
using Remora.Discord.Extensions.Formatting;
using Remora.Results;
namespace TeamOctolings.Octobot.Data.Options;
/// <summary>
/// Represents a per-guild option.
/// </summary>
/// <typeparam name="T">The type of the option.</typeparam>
public class GuildOption<T> : IGuildOption
where T : notnull
{
protected readonly T DefaultValue;
public GuildOption(string name, T defaultValue)
{
Name = name;
DefaultValue = defaultValue;
}
public string Name { get; }
public virtual string Display(JsonNode settings)
{
return Markdown.InlineCode(Get(settings).ToString() ?? throw new InvalidOperationException());
}
/// <summary>
/// Sets the value of the option from a <see cref="string" /> to the provided JsonNode.
/// </summary>
/// <param name="settings">The <see cref="JsonNode" /> to set the value to.</param>
/// <param name="from">The string from which the new value of the option will be parsed.</param>
/// <returns>A value setting result which may or may not have succeeded.</returns>
public virtual Result Set(JsonNode settings, string from)
{
settings[Name] = from;
return Result.Success;
}
public Result Reset(JsonNode settings)
{
settings[Name] = null;
return Result.Success;
}
/// <summary>
/// Gets the value of the option from the provided <paramref name="settings" />.
/// </summary>
/// <param name="settings">The <see cref="JsonNode" /> to get the value from.</param>
/// <returns>The value of the option.</returns>
public virtual T Get(JsonNode settings)
{
var property = settings[Name];
return property != null ? property.GetValue<T>() : DefaultValue;
}
}

View file

@ -0,0 +1,12 @@
using System.Text.Json.Nodes;
using Remora.Results;
namespace TeamOctolings.Octobot.Data.Options;
public interface IGuildOption
{
string Name { get; }
string Display(JsonNode settings);
Result Set(JsonNode settings, string from);
Result Reset(JsonNode settings);
}

View file

@ -0,0 +1,38 @@
using System.Globalization;
using System.Text.Json.Nodes;
using Remora.Discord.Extensions.Formatting;
using Remora.Results;
namespace TeamOctolings.Octobot.Data.Options;
/// <inheritdoc />
public sealed class LanguageOption : GuildOption<CultureInfo>
{
private static readonly Dictionary<string, CultureInfo> CultureInfoCache = new()
{
{ "en", new CultureInfo("en-US") },
{ "ru", new CultureInfo("ru-RU") }
};
public LanguageOption(string name, string defaultValue) : base(name, CultureInfoCache[defaultValue]) { }
public override string Display(JsonNode settings)
{
return Markdown.InlineCode(settings[Name]?.GetValue<string>() ?? "en");
}
/// <inheritdoc />
public override CultureInfo Get(JsonNode settings)
{
var property = settings[Name];
return property != null ? CultureInfoCache[property.GetValue<string>()] : DefaultValue;
}
/// <inheritdoc />
public override Result Set(JsonNode settings, string from)
{
return CultureInfoCache.ContainsKey(from.ToLowerInvariant())
? base.Set(settings, from.ToLowerInvariant())
: new ArgumentInvalidError(nameof(from), Messages.LanguageNotSupported);
}
}

View file

@ -0,0 +1,40 @@
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core;
using Remora.Results;
using TeamOctolings.Octobot.Extensions;
namespace TeamOctolings.Octobot.Data.Options;
public sealed partial class SnowflakeOption : GuildOption<Snowflake>
{
public SnowflakeOption(string name) : base(name, 0UL.ToSnowflake()) { }
public override string Display(JsonNode settings)
{
return Name.EndsWith("Channel", StringComparison.Ordinal)
? Mention.Channel(Get(settings))
: Mention.Role(Get(settings));
}
public override Snowflake Get(JsonNode settings)
{
var property = settings[Name];
return property != null ? property.GetValue<ulong>().ToSnowflake() : DefaultValue;
}
public override Result Set(JsonNode settings, string from)
{
if (!ulong.TryParse(NonNumbers().Replace(from, ""), out var parsed))
{
return new ArgumentInvalidError(nameof(from), Messages.InvalidSettingValue);
}
settings[Name] = parsed;
return Result.Success;
}
[GeneratedRegex("[^0-9]")]
private static partial Regex NonNumbers();
}

View file

@ -0,0 +1,27 @@
using System.Text.Json.Nodes;
using Remora.Results;
using TeamOctolings.Octobot.Parsers;
namespace TeamOctolings.Octobot.Data.Options;
public sealed class TimeSpanOption : GuildOption<TimeSpan>
{
public TimeSpanOption(string name, TimeSpan defaultValue) : base(name, defaultValue) { }
public override TimeSpan Get(JsonNode settings)
{
var property = settings[Name];
return property != null ? TimeSpanParser.TryParse(property.GetValue<string>()).Entity : DefaultValue;
}
public override Result Set(JsonNode settings, string from)
{
if (!TimeSpanParser.TryParse(from).IsDefined(out var span))
{
return new ArgumentInvalidError(nameof(from), Messages.InvalidSettingValue);
}
settings[Name] = span.ToString();
return Result.Success;
}
}

View file

@ -0,0 +1,9 @@
namespace TeamOctolings.Octobot.Data;
public struct Reminder
{
public DateTimeOffset At { get; init; }
public string Text { get; init; }
public ulong ChannelId { get; init; }
public ulong MessageId { get; init; }
}

View file

@ -0,0 +1,41 @@
using System.Text.Json.Serialization;
using Remora.Discord.API.Abstractions.Objects;
namespace TeamOctolings.Octobot.Data;
/// <summary>
/// Stores information about scheduled events. This information is not provided by the Discord API.
/// </summary>
/// <remarks>This information is stored on disk as a JSON file.</remarks>
public sealed class ScheduledEventData
{
public ScheduledEventData(ulong id, string name, DateTimeOffset scheduledStartTime,
GuildScheduledEventStatus status)
{
Id = id;
Name = name;
ScheduledStartTime = scheduledStartTime;
Status = status;
}
[JsonConstructor]
public ScheduledEventData(ulong id, string name, bool earlyNotificationSent, DateTimeOffset scheduledStartTime,
DateTimeOffset? actualStartTime, GuildScheduledEventStatus? status, bool scheduleOnStatusUpdated)
{
Id = id;
Name = name;
EarlyNotificationSent = earlyNotificationSent;
ScheduledStartTime = scheduledStartTime;
ActualStartTime = actualStartTime;
Status = status;
ScheduleOnStatusUpdated = scheduleOnStatusUpdated;
}
public ulong Id { get; }
public string Name { get; set; }
public bool EarlyNotificationSent { get; set; }
public DateTimeOffset ScheduledStartTime { get; set; }
public DateTimeOffset? ActualStartTime { get; set; }
public GuildScheduledEventStatus? Status { get; set; }
public bool ScheduleOnStatusUpdated { get; set; } = true;
}

View file

@ -0,0 +1,29 @@
using OneOf;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects;
using Remora.Rest.Core;
using Remora.Results;
namespace TeamOctolings.Octobot.Extensions;
public static class ChannelApiExtensions
{
public static async Task<Result> CreateMessageWithEmbedResultAsync(this IDiscordRestChannelAPI channelApi,
Snowflake channelId, Optional<string> message = default, Optional<string> nonce = default,
Optional<bool> isTextToSpeech = default, Optional<Result<Embed>> embedResult = default,
Optional<IAllowedMentions> allowedMentions = default, Optional<IMessageReference> messageRefenence = default,
Optional<IReadOnlyList<IMessageComponent>> components = default,
Optional<IReadOnlyList<Snowflake>> stickerIds = default,
Optional<IReadOnlyList<OneOf<FileData, IPartialAttachment>>> attachments = default,
Optional<MessageFlags> flags = default, CancellationToken ct = default)
{
if (!embedResult.IsDefined() || !embedResult.Value.IsDefined(out var embed))
{
return ResultExtensions.FromError(embedResult.Value);
}
return (Result)await channelApi.CreateMessageAsync(channelId, message, nonce, isTextToSpeech, new[] { embed },
allowedMentions, messageRefenence, components, stickerIds, attachments, flags, ct);
}
}

View file

@ -0,0 +1,40 @@
using Remora.Results;
namespace TeamOctolings.Octobot.Extensions;
public static class CollectionExtensions
{
public static TResult? MaxOrDefault<TSource, TResult>(
this IEnumerable<TSource> source, Func<TSource, TResult> selector)
{
var list = source.ToList();
return list.Count > 0 ? list.Max(selector) : default;
}
public static void AddIfFailed(this List<Result> list, Result result)
{
if (!result.IsSuccess)
{
list.Add(result);
}
}
/// <summary>
/// Return an appropriate result for a list of failed results. The list must only contain failed results.
/// </summary>
/// <param name="list">The list of failed results.</param>
/// <returns>
/// A successful result if the list is empty, the only Result in the list, or <see cref="AggregateError" />
/// containing all results from the list.
/// </returns>
/// <exception cref="InvalidOperationException"></exception>
public static Result AggregateErrors(this List<Result> list)
{
return list.Count switch
{
0 => Result.Success,
1 => list[0],
_ => new AggregateError(list.Cast<IResult>().ToArray())
};
}
}

View file

@ -0,0 +1,19 @@
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Extensions;
using Remora.Rest.Core;
namespace TeamOctolings.Octobot.Extensions;
public static class CommandContextExtensions
{
public static bool TryGetContextIDs(
this ICommandContext context, out Snowflake guildId,
out Snowflake channelId, out Snowflake executorId)
{
channelId = default;
executorId = default;
return context.TryGetGuildID(out guildId)
&& context.TryGetChannelID(out channelId)
&& context.TryGetUserID(out executorId);
}
}

View file

@ -0,0 +1,31 @@
using System.Text;
using DiffPlex.DiffBuilder.Model;
namespace TeamOctolings.Octobot.Extensions;
public static class DiffPaneModelExtensions
{
public static string AsMarkdown(this DiffPaneModel model)
{
var builder = new StringBuilder();
foreach (var line in model.Lines)
{
if (line.Type is ChangeType.Deleted)
{
builder.Append("-- ");
}
if (line.Type is ChangeType.Inserted)
{
builder.Append("++ ");
}
if (line.Type is not ChangeType.Imaginary)
{
builder.AppendLine(line.Text.SanitizeForDiffBlock());
}
}
return builder.ToString().InBlockCode("diff");
}
}

View file

@ -0,0 +1,149 @@
using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Objects;
using Remora.Discord.Extensions.Embeds;
using Remora.Rest.Core;
namespace TeamOctolings.Octobot.Extensions;
public static class EmbedBuilderExtensions
{
/// <summary>
/// Adds a footer representing that an action was performed by a <paramref name="user" />.
/// </summary>
/// <param name="builder">The builder to add the footer to.</param>
/// <param name="user">The user that performed the action whose tag and avatar to use.</param>
/// <returns>The builder with the added footer.</returns>
public static EmbedBuilder WithActionFooter(this EmbedBuilder builder, IUser user)
{
var avatarUrlResult = CDN.GetUserAvatarUrl(user, imageSize: 256);
var avatarUrl = avatarUrlResult.IsSuccess
? avatarUrlResult.Entity.AbsoluteUri
: CDN.GetDefaultUserAvatarUrl(user, imageSize: 256).Entity.AbsoluteUri;
return builder.WithFooter(
new EmbedFooter($"{Messages.IssuedBy}:\n{user.GetTag()}", avatarUrl));
}
/// <summary>
/// Adds a title using the author field, making it smaller than using the title field.
/// </summary>
/// <param name="builder">The builder to add the small title to.</param>
/// <param name="text">The text of the small title.</param>
/// <param name="avatarSource">The user whose avatar to use in the small title.</param>
/// <returns>The builder with the added small title in the author field.</returns>
public static EmbedBuilder WithSmallTitle(
this EmbedBuilder builder, string text, IUser? avatarSource = null)
{
Uri? avatarUrl = null;
if (avatarSource is not null)
{
var avatarUrlResult = CDN.GetUserAvatarUrl(avatarSource, imageSize: 256);
avatarUrl = avatarUrlResult.IsSuccess
? avatarUrlResult.Entity
: CDN.GetDefaultUserAvatarUrl(avatarSource, imageSize: 256).Entity;
}
builder.Author = new EmbedAuthorBuilder(text, iconUrl: avatarUrl?.AbsoluteUri);
return builder;
}
/// <summary>
/// Adds a user avatar in the thumbnail field.
/// </summary>
/// <param name="builder">The builder to add the thumbnail to.</param>
/// <param name="avatarSource">The user whose avatar to use in the thumbnail field.</param>
/// <returns>The builder with the added avatar in the thumbnail field.</returns>
public static EmbedBuilder WithLargeUserAvatar(
this EmbedBuilder builder, IUser avatarSource)
{
var avatarUrlResult = CDN.GetUserAvatarUrl(avatarSource, imageSize: 256);
var avatarUrl = avatarUrlResult.IsSuccess
? avatarUrlResult.Entity
: CDN.GetDefaultUserAvatarUrl(avatarSource, imageSize: 256).Entity;
return builder.WithThumbnailUrl(avatarUrl.AbsoluteUri);
}
/// <summary>
/// Adds a guild icon in the thumbnail field.
/// </summary>
/// <param name="builder">The builder to add the thumbnail to.</param>
/// <param name="iconSource">The guild whose icon to use in the thumbnail field.</param>
/// <returns>The builder with the added icon in the thumbnail field.</returns>
public static EmbedBuilder WithLargeGuildIcon(
this EmbedBuilder builder, IGuild iconSource)
{
var iconUrlResult = CDN.GetGuildIconUrl(iconSource, imageSize: 256);
return iconUrlResult.IsSuccess
? builder.WithThumbnailUrl(iconUrlResult.Entity.AbsoluteUri)
: builder;
}
/// <summary>
/// Adds a guild banner in the image field.
/// </summary>
/// <param name="builder">The builder to add the image to.</param>
/// <param name="bannerSource">The guild whose banner to use in the image field.</param>
/// <returns>The builder with the added banner in the image field.</returns>
public static EmbedBuilder WithGuildBanner(
this EmbedBuilder builder, IGuild bannerSource)
{
return bannerSource.Banner is not null
? builder.WithImageUrl(CDN.GetGuildBannerUrl(bannerSource).Entity.AbsoluteUri)
: builder;
}
/// <summary>
/// Adds a footer representing that the action was performed in the <paramref name="guild" />.
/// </summary>
/// <param name="builder">The builder to add the footer to.</param>
/// <param name="guild">The guild whose name and icon to use.</param>
/// <returns>The builder with the added footer.</returns>
public static EmbedBuilder WithGuildFooter(this EmbedBuilder builder, IGuild guild)
{
var iconUrlResult = CDN.GetGuildIconUrl(guild, imageSize: 256);
var iconUrl = iconUrlResult.IsSuccess
? iconUrlResult.Entity.AbsoluteUri
: default(Optional<string>);
return builder.WithFooter(new EmbedFooter(guild.Name, iconUrl));
}
/// <summary>
/// Adds a title representing that the action happened in the <paramref name="guild" />.
/// </summary>
/// <param name="builder">The builder to add the title to.</param>
/// <param name="guild">The guild whose name and icon to use.</param>
/// <returns>The builder with the added title.</returns>
public static EmbedBuilder WithGuildTitle(this EmbedBuilder builder, IGuild guild)
{
var iconUrlResult = CDN.GetGuildIconUrl(guild, imageSize: 256);
var iconUrl = iconUrlResult.IsSuccess
? iconUrlResult.Entity.AbsoluteUri
: null;
builder.Author = new EmbedAuthorBuilder(guild.Name, iconUrl: iconUrl);
return builder;
}
/// <summary>
/// Adds a scheduled event's cover image.
/// </summary>
/// <param name="builder">The builder to add the image to.</param>
/// <param name="eventId">The ID of the scheduled event whose image to use.</param>
/// <param name="imageHashOptional">The Optional containing the image hash.</param>
/// <returns>The builder with the added cover image.</returns>
public static EmbedBuilder WithEventCover(
this EmbedBuilder builder, Snowflake eventId, Optional<IImageHash?> imageHashOptional)
{
if (!imageHashOptional.IsDefined(out var imageHash))
{
return builder;
}
var iconUrlResult = CDN.GetGuildScheduledEventCoverUrl(eventId, imageHash, imageSize: 1024);
return iconUrlResult.IsDefined(out var iconUrl) ? builder.WithImageUrl(iconUrl.AbsoluteUri) : builder;
}
}

View file

@ -0,0 +1,21 @@
using Remora.Discord.API.Objects;
using Remora.Discord.Commands.Feedback.Messages;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Results;
namespace TeamOctolings.Octobot.Extensions;
public static class FeedbackServiceExtensions
{
public static async Task<Result> SendContextualEmbedResultAsync(
this IFeedbackService feedback, Result<Embed> embedResult,
FeedbackMessageOptions? options = null, CancellationToken ct = default)
{
if (!embedResult.IsDefined(out var embed))
{
return ResultExtensions.FromError(embedResult);
}
return (Result)await feedback.SendContextualEmbedAsync(embed, options, ct);
}
}

View file

@ -0,0 +1,28 @@
using Remora.Discord.API.Abstractions.Objects;
using Remora.Rest.Core;
using Remora.Results;
namespace TeamOctolings.Octobot.Extensions;
public static class GuildScheduledEventExtensions
{
public static Result TryGetExternalEventData(this IGuildScheduledEvent scheduledEvent, out DateTimeOffset endTime,
out string? location)
{
endTime = default;
location = default;
if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata))
{
return new ArgumentNullError(nameof(scheduledEvent.EntityMetadata));
}
if (!metadata.Location.IsDefined(out location))
{
return new ArgumentNullError(nameof(metadata.Location));
}
return scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out endTime)
? Result.Success
: new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime));
}
}

View file

@ -0,0 +1,40 @@
using Microsoft.Extensions.Logging;
using Remora.Results;
namespace TeamOctolings.Octobot.Extensions;
public static class LoggerExtensions
{
/// <summary>
/// Checks if the <paramref name="result" /> has failed due to an error that has resulted from neither invalid user
/// input nor the execution environment and logs the error using the provided <paramref name="logger" />.
/// </summary>
/// <remarks>
/// This has special behavior for <see cref="ExceptionError" /> - its exception will be passed to the
/// <paramref name="logger" />
/// </remarks>
/// <param name="logger">The logger to use.</param>
/// <param name="result">The Result whose error check.</param>
/// <param name="message">The message to use if this result has failed.</param>
public static void LogResult(this ILogger logger, IResult result, string? message = "")
{
if (result.IsSuccess)
{
return;
}
if (result.Error is ExceptionError exe)
{
if (exe.Exception is TaskCanceledException)
{
return;
}
logger.LogError(exe.Exception, "{ErrorMessage}", message);
return;
}
logger.LogWarning("{UserMessage}{NewLine}{ResultErrorMessage}", message, Environment.NewLine,
result.Error.Message);
}
}

View file

@ -0,0 +1,16 @@
namespace TeamOctolings.Octobot.Extensions;
public static class MarkdownExtensions
{
/// <summary>
/// Formats a string to use Markdown Bullet formatting.
/// </summary>
/// <param name="text">The input text to format.</param>
/// <returns>
/// A markdown-formatted bullet string.
/// </returns>
public static string BulletPoint(string text)
{
return $"- {text}";
}
}

View file

@ -0,0 +1,65 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Remora.Results;
namespace TeamOctolings.Octobot.Extensions;
public static class ResultExtensions
{
public static Result FromError(Result result)
{
LogResultStackTrace(result);
return result;
}
public static Result FromError<T>(Result<T> result)
{
var casted = (Result)result;
LogResultStackTrace(casted);
return casted;
}
private static void LogResultStackTrace(Result result)
{
if (result.IsSuccess)
{
return;
}
if (Utility.StaticLogger is null)
{
throw new InvalidOperationException();
}
Utility.StaticLogger.LogError("{ErrorType}: {ErrorMessage}{NewLine}{StackTrace}",
result.Error.GetType().FullName, result.Error.Message, Environment.NewLine, ConstructStackTrace());
var inner = result.Inner;
while (inner is { IsSuccess: false })
{
Utility.StaticLogger.LogError("Caused by: {ResultType}: {ResultMessage}",
inner.Error.GetType().FullName, inner.Error.Message);
inner = inner.Inner;
}
}
private static string ConstructStackTrace()
{
var stackArray = new StackTrace(3, true).ToString().Split(Environment.NewLine).ToList();
for (var i = stackArray.Count - 1; i >= 0; i--)
{
var frame = stackArray[i];
var trimmed = frame.TrimStart();
if (trimmed.StartsWith("at System.Threading", StringComparison.Ordinal)
|| trimmed.StartsWith("at System.Runtime.CompilerServices", StringComparison.Ordinal))
{
stackArray.RemoveAt(i);
}
}
return string.Join(Environment.NewLine, stackArray);
}
}

View file

@ -0,0 +1,32 @@
using Remora.Rest.Core;
namespace TeamOctolings.Octobot.Extensions;
public static class SnowflakeExtensions
{
/// <summary>
/// Checks whether this Snowflake has any value set.
/// </summary>
/// <param name="snowflake">The Snowflake to check.</param>
/// <returns>true if the Snowflake has no value set or it's set to 0, false otherwise.</returns>
public static bool Empty(this Snowflake snowflake)
{
return snowflake.Value is 0;
}
/// <summary>
/// Checks whether this snowflake is empty (see <see cref="Empty" />) or it's equal to
/// <paramref name="anotherSnowflake" />
/// </summary>
/// <param name="snowflake">The Snowflake to check for emptiness</param>
/// <param name="anotherSnowflake">The Snowflake to check for equality with <paramref name="snowflake" />.</param>
/// <returns>
/// true if <paramref name="snowflake" /> is empty or is equal to <paramref name="anotherSnowflake" />, false
/// otherwise.
/// </returns>
/// <seealso cref="Empty" />
public static bool EmptyOrEqualTo(this Snowflake snowflake, Snowflake anotherSnowflake)
{
return snowflake.Empty() || snowflake == anotherSnowflake;
}
}

View file

@ -0,0 +1,62 @@
using System.Text;
namespace TeamOctolings.Octobot.Extensions;
public static class StringBuilderExtensions
{
/// <summary>
/// Appends the input string with Markdown Bullet formatting to the specified <see cref="StringBuilder" /> object.
/// </summary>
/// <param name="builder">The <see cref="StringBuilder" /> object.</param>
/// <param name="value">The string to append with bullet point.</param>
/// <returns>
/// The builder with the appended string with Markdown Bullet formatting.
/// </returns>
public static StringBuilder AppendBulletPoint(this StringBuilder builder, string? value)
{
return builder.Append("- ").Append(value);
}
/// <summary>
/// Appends the input string with Markdown Sub-Bullet formatting to the specified <see cref="StringBuilder" /> object.
/// </summary>
/// <param name="builder">The <see cref="StringBuilder" /> object.</param>
/// <param name="value">The string to append with sub-bullet point.</param>
/// <returns>
/// The builder with the appended string with Markdown Sub-Bullet formatting.
/// </returns>
public static StringBuilder AppendSubBulletPoint(this StringBuilder builder, string? value)
{
return builder.Append(" - ").Append(value);
}
/// <summary>
/// Appends the input string with Markdown Bullet formatting followed by
/// the default line terminator to the end of specified <see cref="StringBuilder" /> object.
/// </summary>
/// <param name="builder">The <see cref="StringBuilder" /> object.</param>
/// <param name="value">The string to append with bullet point.</param>
/// <returns>
/// The builder with the appended string with Markdown Bullet formatting
/// and default line terminator at the end.
/// </returns>
public static StringBuilder AppendBulletPointLine(this StringBuilder builder, string? value)
{
return builder.Append("- ").AppendLine(value);
}
/// <summary>
/// Appends the input string with Markdown Sub-Bullet formatting followed by
/// the default line terminator to the end of specified <see cref="StringBuilder" /> object.
/// </summary>
/// <param name="builder">The <see cref="StringBuilder" /> object.</param>
/// <param name="value">The string to append with sub-bullet point.</param>
/// <returns>
/// The builder with the appended string with Markdown Sub-Bullet formatting
/// and default line terminator at the end.
/// </returns>
public static StringBuilder AppendSubBulletPointLine(this StringBuilder builder, string? value)
{
return builder.Append(" - ").AppendLine(value);
}
}

View file

@ -0,0 +1,66 @@
using System.Net;
using Remora.Discord.Extensions.Formatting;
namespace TeamOctolings.Octobot.Extensions;
public static class StringExtensions
{
private const string ZeroWidthSpace = "";
/// <summary>
/// Sanitizes a string for use in <see cref="Markdown.BlockCode(string)" /> by inserting zero-width spaces in between
/// symbols used to format the string with block code.
/// </summary>
/// <param name="s">The string to sanitize.</param>
/// <returns>The sanitized string that can be safely used in <see cref="Markdown.BlockCode(string)" />.</returns>
private static string SanitizeForBlockCode(this string s)
{
return s.Replace("```", $"{ZeroWidthSpace}`{ZeroWidthSpace}`{ZeroWidthSpace}`{ZeroWidthSpace}");
}
/// <summary>
/// Sanitizes a string for use in <see cref="Markdown.BlockCode(string, string)" /> when "language" is "diff" by
/// prepending a zero-width space before the input string to prevent Discord from applying syntax highlighting.
/// </summary>
/// <remarks>This does not call <see cref="SanitizeForBlockCode"/>, you have to do so yourself if needed.</remarks>
/// <param name="s">The string to sanitize.</param>
/// <returns>The sanitized string that can be safely used in <see cref="Markdown.BlockCode(string, string)" /> with "diff" as the language.</returns>
public static string SanitizeForDiffBlock(this string s)
{
return $"{ZeroWidthSpace}{s}";
}
/// <summary>
/// Sanitizes a string (see <see cref="SanitizeForBlockCode" />) and formats the string to use Markdown Block Code
/// formatting with a specified
/// language for syntax highlighting.
/// </summary>
/// <param name="s">The string to sanitize and format.</param>
/// <param name="language"></param>
/// <returns>
/// The sanitized string formatted to use Markdown Block Code with a specified
/// language for syntax highlighting.
/// </returns>
public static string InBlockCode(this string s, string language = "")
{
s = s.SanitizeForBlockCode();
return
$"```{language}\n{s.SanitizeForBlockCode()}{(s.EndsWith('`') || string.IsNullOrWhiteSpace(s) ? " " : "")}```";
}
public static string Localized(this string key)
{
return Messages.ResourceManager.GetString(key, Messages.Culture) ?? key;
}
/// <summary>
/// Encodes a string to allow its transmission in request headers.
/// </summary>
/// <remarks>Used when encountering "Request headers must contain only ASCII characters".</remarks>
/// <param name="s">The string to encode.</param>
/// <returns>An encoded string with spaces kept intact.</returns>
public static string EncodeHeader(this string s)
{
return WebUtility.UrlEncode(s).Replace('+', ' ');
}
}

View file

@ -0,0 +1,12 @@
using Remora.Discord.API;
using Remora.Rest.Core;
namespace TeamOctolings.Octobot.Extensions;
public static class UInt64Extensions
{
public static Snowflake ToSnowflake(this ulong id)
{
return DiscordSnowflake.New(id);
}
}

View file

@ -0,0 +1,11 @@
using Remora.Discord.API.Abstractions.Objects;
namespace TeamOctolings.Octobot.Extensions;
public static class UserExtensions
{
public static string GetTag(this IUser user)
{
return user.Discriminator is 0000 ? $"@{user.Username}" : $"{user.Username}#{user.Discriminator:0000}";
}
}

1200
TeamOctolings.Octobot/Messages.Designer.cs generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,684 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader><resheader name="version">2.0</resheader><resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader><resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader><data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data><data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data><data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64"><value>[base64 mime encoded serialized .NET Framework object]</value></data><data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64"><value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value><comment>This is a comment</comment></data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata" id="root" xmlns="">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 </value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 </value>
</resheader>
<data name="Ready" xml:space="preserve">
<value>I'm ready!</value>
</data>
<data name="CachedMessageDeleted" xml:space="preserve">
<value>Deleted message by {0}:</value>
</data>
<data name="CachedMessageEdited" xml:space="preserve">
<value>Edited message by {0}:</value>
</data>
<data name="DefaultWelcomeMessage" xml:space="preserve">
<value>{0}, welcome to {1}</value>
</data>
<data name="Generic1" xml:space="preserve">
<value>Veemo!</value>
</data>
<data name="Generic2" xml:space="preserve">
<value>Woomy!</value>
</data>
<data name="Generic3" xml:space="preserve">
<value>Ngyes!</value>
</data>
<data name="YouWereBanned" xml:space="preserve">
<value>You were banned</value>
</data>
<data name="PunishmentExpired" xml:space="preserve">
<value>Punishment expired</value>
</data>
<data name="YouWereKicked" xml:space="preserve">
<value>You were kicked</value>
</data>
<data name="Milliseconds" xml:space="preserve">
<value>ms</value>
</data>
<data name="ChannelNotSpecified" xml:space="preserve">
<value>Not specified</value>
</data>
<data name="RoleNotSpecified" xml:space="preserve">
<value>Not specified</value>
</data>
<data name="SettingsLanguage" xml:space="preserve">
<value>Language</value>
</data>
<data name="SettingsPrefix" xml:space="preserve">
<value>Prefix</value>
</data>
<data name="SettingsRemoveRolesOnMute" xml:space="preserve">
<value>Remove roles on mute</value>
</data>
<data name="SettingsSendWelcomeMessages" xml:space="preserve">
<value>Send welcome messages</value>
</data>
<data name="SettingsMuteRole" xml:space="preserve">
<value>Mute role</value>
</data>
<data name="LanguageNotSupported" xml:space="preserve">
<value>Language not supported!</value>
</data>
<data name="Yes" xml:space="preserve">
<value>Yes</value>
</data>
<data name="No" xml:space="preserve">
<value>No</value>
</data>
<data name="UserNotBanned" xml:space="preserve">
<value>This user is not banned!</value>
</data>
<data name="MemberNotMuted" xml:space="preserve">
<value>Member not muted!</value>
</data>
<data name="SettingsWelcomeMessage" xml:space="preserve">
<value>Welcome message</value>
</data>
<data name="UserBanned" xml:space="preserve">
<value>{0} was banned</value>
</data>
<data name="SettingsReceiveStartupMessages" xml:space="preserve">
<value>Receive startup messages</value>
</data>
<data name="InvalidSettingValue" xml:space="preserve">
<value>Invalid setting value specified!</value>
</data>
<data name="DurationRequiredForTimeOuts" xml:space="preserve">
<value>I cannot mute someone for more than 28 days using timeouts! Either specify a duration shorter than 28 days, or set a mute role in settings</value>
</data>
<data name="CannotTimeOutBot" xml:space="preserve">
<value>I cannot use time-outs on other bots! Try to set a mute role in settings</value>
</data>
<data name="SettingsEventNotificationRole" xml:space="preserve">
<value>Role for event creation notifications</value>
</data>
<data name="SettingsEventNotificationChannel" xml:space="preserve">
<value>Channel for event notifications</value>
</data>
<data name="SettingsEventStartedReceivers" xml:space="preserve">
<value>Event start notifications receivers</value>
</data>
<data name="EventStarted" xml:space="preserve">
<value>Event "{0}" started</value>
</data>
<data name="EventCancelled" xml:space="preserve">
<value>Event "{0}" is cancelled!</value>
</data>
<data name="EventCompleted" xml:space="preserve">
<value>Event "{0}" has completed!</value>
</data>
<data name="MessagesCleared" xml:space="preserve">
<value>Cleared {0} messages</value>
</data>
<data name="SettingsNothingChanged" xml:space="preserve">
<value>Nothing changed! `{0}` is already set to {1}</value>
</data>
<data name="SettingNotDefined" xml:space="preserve">
<value>Not specified</value>
</data>
<data name="MissingUser" xml:space="preserve">
<value>You need to specify a user!</value>
</data>
<data name="UserCannotBanMembers" xml:space="preserve">
<value>You cannot ban users from this guild!</value>
</data>
<data name="UserCannotManageMessages" xml:space="preserve">
<value>You cannot manage messages in this guild!</value>
</data>
<data name="UserCannotKickMembers" xml:space="preserve">
<value>You cannot kick members from this guild!</value>
</data>
<data name="UserCannotMuteMembers" xml:space="preserve">
<value>You cannot mute members in this guild!</value>
</data>
<data name="UserCannotUnmuteMembers" xml:space="preserve">
<value>You cannot unmute members in this guild!</value>
</data>
<data name="UserCannotManageGuild" xml:space="preserve">
<value>You cannot manage this guild!</value>
</data>
<data name="BotCannotBanMembers" xml:space="preserve">
<value>I cannot ban users from this guild!</value>
</data>
<data name="BotCannotManageMessages" xml:space="preserve">
<value>I cannot manage messages in this guild!</value>
</data>
<data name="BotCannotKickMembers" xml:space="preserve">
<value>I cannot kick members from this guild!</value>
</data>
<data name="BotCannotModerateMembers" xml:space="preserve">
<value>I cannot moderate members in this guild!</value>
</data>
<data name="BotCannotManageGuild" xml:space="preserve">
<value>I cannot manage this guild!</value>
</data>
<data name="UserCannotBanOwner" xml:space="preserve">
<value>You cannot ban the owner of this guild!</value>
</data>
<data name="UserCannotBanThemselves" xml:space="preserve">
<value>You cannot ban yourself!</value>
</data>
<data name="UserCannotBanBot" xml:space="preserve">
<value>You cannot ban me!</value>
</data>
<data name="BotCannotBanTarget" xml:space="preserve">
<value>I cannot ban this user!</value>
</data>
<data name="UserCannotBanTarget" xml:space="preserve">
<value>You cannot ban this user!</value>
</data>
<data name="UserCannotKickOwner" xml:space="preserve">
<value>You cannot kick the owner of this guild!</value>
</data>
<data name="UserCannotKickThemselves" xml:space="preserve">
<value>You cannot kick yourself!</value>
</data>
<data name="UserCannotKickBot" xml:space="preserve">
<value>You cannot kick me!</value>
</data>
<data name="BotCannotKickTarget" xml:space="preserve">
<value>I cannot kick this member!</value>
</data>
<data name="UserCannotKickTarget" xml:space="preserve">
<value>You cannot kick this member!</value>
</data>
<data name="UserCannotMuteOwner" xml:space="preserve">
<value>You cannot mute the owner of this guild!</value>
</data>
<data name="UserCannotMuteThemselves" xml:space="preserve">
<value>You cannot mute yourself!</value>
</data>
<data name="UserCannotMuteBot" xml:space="preserve">
<value>You cannot mute me!</value>
</data>
<data name="BotCannotMuteTarget" xml:space="preserve">
<value>I cannot mute this member!</value>
</data>
<data name="UserCannotMuteTarget" xml:space="preserve">
<value>You cannot mute this member!</value>
</data>
<data name="UserCannotUnmuteOwner" xml:space="preserve">
<value>You don't need to unmute the owner of this guild!</value>
</data>
<data name="UserCannotUnmuteThemselves" xml:space="preserve">
<value>You are muted!</value>
</data>
<data name="UserCannotUnmuteBot" xml:space="preserve">
<value>...</value>
</data>
<data name="BotCannotUnmuteTarget" xml:space="preserve">
<value>I cannot unmute this member!</value>
</data>
<data name="UserCannotUnmuteTarget" xml:space="preserve">
<value>You cannot unmute this user!</value>
</data>
<data name="EventEarlyNotification" xml:space="preserve">
<value>Event "{0}" will start {1}!</value>
</data>
<data name="SettingsEventEarlyNotificationOffset" xml:space="preserve">
<value>Early event start notification offset</value>
</data>
<data name="UserNotFound" xml:space="preserve">
<value>I could not find this user in any guild I'm a member of! Check if the ID is correct and that the user was on this server no longer than 30 days ago</value>
</data>
<data name="SettingsDefaultRole" xml:space="preserve">
<value>Default role</value>
</data>
<data name="SettingsPublicFeedbackChannel" xml:space="preserve">
<value>Channel for public notifications</value>
</data>
<data name="SettingsPrivateFeedbackChannel" xml:space="preserve">
<value>Channel for private notifications</value>
</data>
<data name="SettingsReturnRolesOnRejoin" xml:space="preserve">
<value>Return roles on rejoin</value>
</data>
<data name="SettingsAutoStartEvents" xml:space="preserve">
<value>Automatically start scheduled events</value>
</data>
<data name="IssuedBy" xml:space="preserve">
<value>Issued by</value>
</data>
<data name="EventCreatedTitle" xml:space="preserve">
<value>{0} has created a new event:</value>
</data>
<data name="DescriptionLocalEventCreated" xml:space="preserve">
<value>The event will start at {0} in {1}</value>
</data>
<data name="DescriptionExternalEventCreated" xml:space="preserve">
<value>The event will start at {0} until {1} in {2}</value>
</data>
<data name="ButtonOpenEventInfo" xml:space="preserve">
<value>Open Event Info</value>
</data>
<data name="EventDuration" xml:space="preserve">
<value>The event has lasted for `{0}`</value>
</data>
<data name="DescriptionLocalEventStarted" xml:space="preserve">
<value>The event is happening at {0}</value>
</data>
<data name="DescriptionExternalEventStarted" xml:space="preserve">
<value>The event is happening at {0} until {1}</value>
</data>
<data name="UserAlreadyBanned" xml:space="preserve">
<value>This user is already banned!</value>
</data>
<data name="UserUnbanned" xml:space="preserve">
<value>{0} was unbanned</value>
</data>
<data name="UserMuted" xml:space="preserve">
<value>{0} was muted</value>
</data>
<data name="UserUnmuted" xml:space="preserve">
<value>{0} was unmuted</value>
</data>
<data name="UserNotMuted" xml:space="preserve">
<value>This member is not muted!</value>
</data>
<data name="UserNotFoundShort" xml:space="preserve">
<value>I could not find this user!</value>
</data>
<data name="UserKicked" xml:space="preserve">
<value>{0} was kicked</value>
</data>
<data name="DescriptionActionReason" xml:space="preserve">
<value>Reason: {0}</value>
</data>
<data name="DescriptionActionExpiresAt" xml:space="preserve">
<value>Expires at: {0}</value>
</data>
<data name="UserAlreadyMuted" xml:space="preserve">
<value>This user is already muted!</value>
</data>
<data name="MessageFrom" xml:space="preserve">
<value>From {0}:</value>
</data>
<data name="AboutTitleDevelopers" xml:space="preserve">
<value>Developers:</value>
</data>
<data name="ButtonOpenRepository" xml:space="preserve">
<value>Octobot's source code</value>
</data>
<data name="AboutBot" xml:space="preserve">
<value>About {0}</value>
</data>
<data name="AboutDeveloper@mctaylors" xml:space="preserve">
<value>developer &amp; designer, Octobot's Wiki creator</value>
</data>
<data name="AboutDeveloper@Octol1ttle" xml:space="preserve">
<value>main developer</value>
</data>
<data name="AboutDeveloper@neroduckale" xml:space="preserve">
<value>developer</value>
</data>
<data name="ReminderCreated" xml:space="preserve">
<value>Reminder for {0} created</value>
</data>
<data name="Reminder" xml:space="preserve">
<value>Reminder for {0}</value>
</data>
<data name="DescriptionReminder" xml:space="preserve">
<value>You asked me to remind you {0}</value>
</data>
<data name="SettingsListTitle" xml:space="preserve">
<value>Octobot's Settings</value>
</data>
<data name="SettingSuccessfullyChanged" xml:space="preserve">
<value>Setting successfully changed</value>
</data>
<data name="SettingNotChanged" xml:space="preserve">
<value>Setting not changed</value>
</data>
<data name="SettingIsNow" xml:space="preserve">
<value>is now</value>
</data>
<data name="SettingsRenameHoistedUsers" xml:space="preserve">
<value>Rename members who attempt to hoist themselves</value>
</data>
<data name="Page" xml:space="preserve">
<value>Page</value>
</data>
<data name="PageNotFound" xml:space="preserve">
<value>Page not found!</value>
</data>
<data name="PagesAllowed" xml:space="preserve">
<value>There are {0} total pages</value>
</data>
<data name="Next" xml:space="preserve">
<value>Next</value>
</data>
<data name="Previous" xml:space="preserve">
<value>Previous</value>
</data>
<data name="ReminderList" xml:space="preserve">
<value>{0}'s reminders</value>
</data>
<data name="InvalidReminderPosition" xml:space="preserve">
<value>There's no reminder in this position!</value>
</data>
<data name="ReminderDeleted" xml:space="preserve">
<value>Reminder deleted</value>
</data>
<data name="NoRemindersFound" xml:space="preserve">
<value>You don't have any reminders created!</value>
</data>
<data name="SingleSettingReset" xml:space="preserve">
<value>Setting {0} reset</value>
</data>
<data name="AllSettingsReset" xml:space="preserve">
<value>All settings have been reset</value>
</data>
<data name="DescriptionActionJumpToMessage" xml:space="preserve">
<value>Jump to message: {0}</value>
</data>
<data name="DescriptionActionJumpToChannel" xml:space="preserve">
<value>Jump to channel: {0}</value>
</data>
<data name="ReminderPosition" xml:space="preserve">
<value>Position in list: {0}</value>
</data>
<data name="ReminderTime" xml:space="preserve">
<value>Reminder send time: {0}</value>
</data>
<data name="ReminderText" xml:space="preserve">
<value>Reminder text: {0}</value>
</data>
<data name="UserInfoDisplayName" xml:space="preserve">
<value>Display name</value>
</data>
<data name="InformationAbout" xml:space="preserve">
<value>Information about {0}</value>
</data>
<data name="UserInfoMuted" xml:space="preserve">
<value>Muted</value>
</data>
<data name="UserInfoDiscordUserSince" xml:space="preserve">
<value>Discord user since</value>
</data>
<data name="UserInfoBanned" xml:space="preserve">
<value>Banned</value>
</data>
<data name="UserInfoPunishments" xml:space="preserve">
<value>Punishments</value>
</data>
<data name="UserInfoBannedPermanently" xml:space="preserve">
<value>Banned permanently</value>
</data>
<data name="UserInfoNotOnGuild" xml:space="preserve">
<value>Not in the guild</value>
</data>
<data name="UserInfoMutedByTimeout" xml:space="preserve">
<value>Muted by timeout</value>
</data>
<data name="UserInfoMutedByMuteRole" xml:space="preserve">
<value>Muted by mute role</value>
</data>
<data name="UserInfoGuildMemberSince" xml:space="preserve">
<value>Guild member since</value>
</data>
<data name="UserInfoGuildNickname" xml:space="preserve">
<value>Nickname</value>
</data>
<data name="UserInfoGuildRoles" xml:space="preserve">
<value>Roles</value>
</data>
<data name="UserInfoGuildMemberPremiumSince" xml:space="preserve">
<value>Nitro booster since</value>
</data>
<data name="RandomTitle" xml:space="preserve">
<value>Random number for {0} is:</value>
</data>
<data name="RandomMinMaxSame" xml:space="preserve">
<value>Isn't it obvious?</value>
</data>
<data name="RandomMin" xml:space="preserve">
<value>Minimum number: {0}</value>
</data>
<data name="RandomMax" xml:space="preserve">
<value>Maximum number: {0}</value>
</data>
<data name="Default" xml:space="preserve">
<value>(default)</value>
</data>
<data name="TimestampTitle" xml:space="preserve">
<value>Timestamp for {0}:</value>
</data>
<data name="TimestampOffset" xml:space="preserve">
<value>Offset: {0}</value>
</data>
<data name="GuildInfoDescription" xml:space="preserve">
<value>Guild description</value>
</data>
<data name="GuildInfoCreatedAt" xml:space="preserve">
<value>Creation date</value>
</data>
<data name="GuildInfoOwner" xml:space="preserve">
<value>Guild owner</value>
</data>
<data name="GuildInfoServerBoost" xml:space="preserve">
<value>Server Boost</value>
</data>
<data name="GuildInfoBoostTier" xml:space="preserve">
<value>Boost level</value>
</data>
<data name="GuildInfoBoostCount" xml:space="preserve">
<value>Boost count</value>
</data>
<data name="NoMessagesToClear" xml:space="preserve">
<value>There are no messages matching your filter!</value>
</data>
<data name="MessagesClearedFiltered" xml:space="preserve">
<value>Cleared {0} messages from {1}</value>
</data>
<data name="DataLoadFailedTitle" xml:space="preserve">
<value>An error occurred during guild data load.</value>
</data>
<data name="DataLoadFailedDescription" xml:space="preserve">
<value>This will lead to unexpected behavior. Data will no longer be saved</value>
</data>
<data name="CommandExecutionFailed" xml:space="preserve">
<value>An error occurred during command execution, try again later.</value>
</data>
<data name="ContactDevelopers" xml:space="preserve">
<value>Contact the developers if the problem occurs again.</value>
</data>
<data name="ButtonReportIssue" xml:space="preserve">
<value>Report an issue</value>
</data>
<data name="DefaultLeaveMessage" xml:space="preserve">
<value>See you soon, {0}!</value>
</data>
<data name="SettingsLeaveMessage" xml:space="preserve">
<value>Leave message</value>
</data>
<data name="InvalidTimeSpan" xml:space="preserve">
<value>Time specified incorrectly!</value>
</data>
<data name="UserInfoKicked" xml:space="preserve">
<value>Kicked</value>
</data>
<data name="ReminderEdited" xml:space="preserve">
<value>Reminder edited</value>
</data>
<data name="EightBallPositive1" xml:space="preserve">
<value>It is certain</value>
</data>
<data name="EightBallPositive2" xml:space="preserve">
<value>It is decidedly so</value>
</data>
<data name="EightBallPositive3" xml:space="preserve">
<value>Without a doubt</value>
</data>
<data name="EightBallPositive4" xml:space="preserve">
<value>Yes — definitely</value>
</data>
<data name="EightBallPositive5" xml:space="preserve">
<value>You may rely on it</value>
</data>
<data name="EightBallQuestionable1" xml:space="preserve">
<value>As I see it, yes</value>
</data>
<data name="EightBallQuestionable2" xml:space="preserve">
<value>Most likely</value>
</data>
<data name="EightBallQuestionable3" xml:space="preserve">
<value>Outlook good</value>
</data>
<data name="EightBallQuestionable4" xml:space="preserve">
<value>Signs point to yes</value>
</data>
<data name="EightBallQuestionable5" xml:space="preserve">
<value>Yes</value>
</data>
<data name="EightBallNeutral1" xml:space="preserve">
<value>Reply hazy, try again</value>
</data>
<data name="EightBallNeutral2" xml:space="preserve">
<value>Ask again later</value>
</data>
<data name="EightBallNeutral3" xml:space="preserve">
<value>Better not tell you now</value>
</data>
<data name="EightBallNeutral4" xml:space="preserve">
<value>Cannot predict now</value>
</data>
<data name="EightBallNeutral5" xml:space="preserve">
<value>Concentrate and ask again</value>
</data>
<data name="EightBallNegative1" xml:space="preserve">
<value>Dont count on it</value>
</data>
<data name="EightBallNegative2" xml:space="preserve">
<value>My reply is no</value>
</data>
<data name="EightBallNegative3" xml:space="preserve">
<value>My sources say no</value>
</data>
<data name="EightBallNegative4" xml:space="preserve">
<value>Outlook not so good</value>
</data>
<data name="EightBallNegative5" xml:space="preserve">
<value>Very doubtful</value>
</data>
<data name="TimeSpanExample" xml:space="preserve">
<value>Example of a valid input: `1h30m`</value>
</data>
<data name="Version" xml:space="preserve">
<value>Version: {0}</value>
</data>
<data name="SettingsWelcomeMessagesChannel" xml:space="preserve">
<value>Welcome messages channel</value>
</data>
<data name="ButtonDirty" xml:space="preserve">
<value>Can't report an issue in the development version</value>
</data>
<data name="ButtonOpenWiki" xml:space="preserve">
<value>Open Octobot's Wiki</value>
</data>
<data name="SettingsModeratorRole" xml:space="preserve">
<value>Moderator role</value>
</data>
</root>

View file

@ -0,0 +1,684 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader><resheader name="version">2.0</resheader><resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader><resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader><data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data><data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data><data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64"><value>[base64 mime encoded serialized .NET Framework object]</value></data><data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64"><value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value><comment>This is a comment</comment></data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata" id="root" xmlns="">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 </value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 </value>
</resheader>
<data name="Ready" xml:space="preserve">
<value>Я запустился!</value>
</data>
<data name="CachedMessageDeleted" xml:space="preserve">
<value>Сообщение {0} удалено:</value>
</data>
<data name="CachedMessageEdited" xml:space="preserve">
<value>Сообщение {0} отредактировано:</value>
</data>
<data name="DefaultWelcomeMessage" xml:space="preserve">
<value>{0}, добро пожаловать на сервер {1}</value>
</data>
<data name="Generic1" xml:space="preserve">
<value>Виимо!</value>
</data>
<data name="Generic2" xml:space="preserve">
<value>Вууми!</value>
</data>
<data name="Generic3" xml:space="preserve">
<value>Нгьес!</value>
</data>
<data name="PunishmentExpired" xml:space="preserve">
<value>Время наказания истекло</value>
</data>
<data name="YouWereKicked" xml:space="preserve">
<value>Вы были выгнаны</value>
</data>
<data name="Milliseconds" xml:space="preserve">
<value>мс</value>
</data>
<data name="ChannelNotSpecified" xml:space="preserve">
<value>Не указан</value>
</data>
<data name="RoleNotSpecified" xml:space="preserve">
<value>Не указана</value>
</data>
<data name="SettingsLanguage" xml:space="preserve">
<value>Язык</value>
</data>
<data name="SettingsPrefix" xml:space="preserve">
<value>Префикс</value>
</data>
<data name="SettingsRemoveRolesOnMute" xml:space="preserve">
<value>Удалять роли при муте</value>
</data>
<data name="SettingsSendWelcomeMessages" xml:space="preserve">
<value>Отправлять приветствия</value>
</data>
<data name="SettingsMuteRole" xml:space="preserve">
<value>Роль мута</value>
</data>
<data name="LanguageNotSupported" xml:space="preserve">
<value>Язык не поддерживается! </value>
</data>
<data name="Yes" xml:space="preserve">
<value>Да</value>
</data>
<data name="No" xml:space="preserve">
<value>Нет</value>
</data>
<data name="UserNotBanned" xml:space="preserve">
<value>Этот пользователь не забанен!</value>
</data>
<data name="MemberNotMuted" xml:space="preserve">
<value>Участник не заглушен!</value>
</data>
<data name="SettingsWelcomeMessage" xml:space="preserve">
<value>Приветствие</value>
</data>
<data name="UserBanned" xml:space="preserve">
<value>{0} был(-а) забанен(-а)</value>
</data>
<data name="SettingsReceiveStartupMessages" xml:space="preserve">
<value>Получать сообщения о запуске</value>
</data>
<data name="InvalidSettingValue" xml:space="preserve">
<value>Указано недействительное значение для настройки!</value>
</data>
<data name="DurationRequiredForTimeOuts" xml:space="preserve">
<value>Я не могу заглушить кого-то на более чем 28 дней, используя тайм-ауты! Или укажи продолжительность менее 28 дней, или установи роль мута в настройках</value>
</data>
<data name="CannotTimeOutBot" xml:space="preserve">
<value>Я не могу использовать тайм-ауты на других ботах! Попробуй указать роль мута в настройках</value>
</data>
<data name="SettingsEventNotificationRole" xml:space="preserve">
<value>Роль для уведомлений о создании событий</value>
</data>
<data name="SettingsEventNotificationChannel" xml:space="preserve">
<value>Канал для уведомлений о событиях</value>
</data>
<data name="SettingsEventStartedReceivers" xml:space="preserve">
<value>Получатели уведомлений о начале событий</value>
</data>
<data name="EventStarted" xml:space="preserve">
<value>Событие "{0}" началось</value>
</data>
<data name="EventCancelled" xml:space="preserve">
<value>Событие "{0}" отменено!</value>
</data>
<data name="EventCompleted" xml:space="preserve">
<value>Событие "{0}" завершено!</value>
</data>
<data name="MessagesCleared" xml:space="preserve">
<value>Очищено {0} сообщений</value>
</data>
<data name="SettingsNothingChanged" xml:space="preserve">
<value>Ничего не изменилось! Значение настройки `{0}` уже {1}</value>
</data>
<data name="SettingNotDefined" xml:space="preserve">
<value>Не указано</value>
</data>
<data name="MissingUser" xml:space="preserve">
<value>Надо указать пользователя!</value>
</data>
<data name="UserCannotBanMembers" xml:space="preserve">
<value>Ты не можешь банить пользователей на этом сервере!</value>
</data>
<data name="UserCannotManageMessages" xml:space="preserve">
<value>Ты не можешь управлять сообщениями этого сервера!</value>
</data>
<data name="UserCannotKickMembers" xml:space="preserve">
<value>Ты не можешь выгонять участников с этого сервера!</value>
</data>
<data name="UserCannotMuteMembers" xml:space="preserve">
<value>Ты не можешь глушить участников этого сервера!</value>
</data>
<data name="UserCannotUnmuteMembers" xml:space="preserve">
<value>Ты не можешь разглушать участников этого сервера!</value>
</data>
<data name="UserCannotManageGuild" xml:space="preserve">
<value>Ты не можешь настраивать этот сервер!</value>
</data>
<data name="BotCannotBanMembers" xml:space="preserve">
<value>Я не могу банить пользователей на этом сервере!</value>
</data>
<data name="BotCannotManageMessages" xml:space="preserve">
<value>Я не могу управлять сообщениями этого сервера!</value>
</data>
<data name="BotCannotKickMembers" xml:space="preserve">
<value>Я не могу выгонять участников с этого сервера!</value>
</data>
<data name="BotCannotModerateMembers" xml:space="preserve">
<value>Я не могу модерировать участников этого сервера!</value>
</data>
<data name="BotCannotManageGuild" xml:space="preserve">
<value>Я не могу настраивать этот сервер!</value>
</data>
<data name="UserCannotBanBot" xml:space="preserve">
<value>Ты не можешь меня забанить!</value>
</data>
<data name="UserCannotBanOwner" xml:space="preserve">
<value>Ты не можешь забанить владельца этого сервера!</value>
</data>
<data name="UserCannotBanTarget" xml:space="preserve">
<value>Ты не можешь забанить этого участника!</value>
</data>
<data name="UserCannotBanThemselves" xml:space="preserve">
<value>Ты не можешь себя забанить!</value>
</data>
<data name="BotCannotBanTarget" xml:space="preserve">
<value>Я не могу забанить этого пользователя!</value>
</data>
<data name="UserCannotKickOwner" xml:space="preserve">
<value>Ты не можешь выгнать владельца этого сервера!</value>
</data>
<data name="UserCannotKickThemselves" xml:space="preserve">
<value>Ты не можешь себя выгнать!</value>
</data>
<data name="UserCannotKickBot" xml:space="preserve">
<value>Ты не можешь меня выгнать!</value>
</data>
<data name="BotCannotKickTarget" xml:space="preserve">
<value>Я не могу выгнать этого участника</value>
</data>
<data name="UserCannotKickTarget" xml:space="preserve">
<value>Ты не можешь выгнать этого участника!</value>
</data>
<data name="UserCannotMuteOwner" xml:space="preserve">
<value>Ты не можешь заглушить владельца этого сервера!</value>
</data>
<data name="UserCannotMuteThemselves" xml:space="preserve">
<value>Ты не можешь себя заглушить!</value>
</data>
<data name="UserCannotMuteBot" xml:space="preserve">
<value>Ты не можешь заглушить меня!</value>
</data>
<data name="BotCannotMuteTarget" xml:space="preserve">
<value>Я не могу заглушить этого пользователя!</value>
</data>
<data name="UserCannotMuteTarget" xml:space="preserve">
<value>Ты не можешь заглушить этого участника!</value>
</data>
<data name="UserCannotUnmuteOwner" xml:space="preserve">
<value>Тебе не надо возвращать из мута владельца этого сервера!</value>
</data>
<data name="UserCannotUnmuteThemselves" xml:space="preserve">
<value>Ты заглушен!</value>
</data>
<data name="UserCannotUnmuteBot" xml:space="preserve">
<value>... </value>
</data>
<data name="UserCannotUnmuteTarget" xml:space="preserve">
<value>Ты не можешь вернуть из мута этого пользователя!</value>
</data>
<data name="BotCannotUnmuteTarget" xml:space="preserve">
<value>Я не могу вернуть из мута этого пользователя!</value>
</data>
<data name="EventEarlyNotification" xml:space="preserve">
<value>Событие "{0}" начнется {1}!</value>
</data>
<data name="SettingsEventEarlyNotificationOffset" xml:space="preserve">
<value>Офсет отправки преждевременного уведомления о начале события</value>
</data>
<data name="UserNotFound" xml:space="preserve">
<value>Я не смог найти этого пользователя ни в одном из серверов, в которых я есть. Проверь правильность ID и нахождение пользователя на этом сервере максимум 30 дней назад</value>
</data>
<data name="SettingsDefaultRole" xml:space="preserve">
<value>Роль по умолчанию</value>
</data>
<data name="SettingsPublicFeedbackChannel" xml:space="preserve">
<value>Канал для публичных уведомлений</value>
</data>
<data name="SettingsPrivateFeedbackChannel" xml:space="preserve">
<value>Канал для приватных уведомлений</value>
</data>
<data name="SettingsReturnRolesOnRejoin" xml:space="preserve">
<value>Возвращать роли при перезаходе</value>
</data>
<data name="SettingsAutoStartEvents" xml:space="preserve">
<value>Автоматически начинать события</value>
</data>
<data name="IssuedBy" xml:space="preserve">
<value>Ответственный</value>
</data>
<data name="EventCreatedTitle" xml:space="preserve">
<value>{0} создаёт новое событие:</value>
</data>
<data name="DescriptionLocalEventCreated" xml:space="preserve">
<value>Событие пройдёт {0} в канале {1}</value>
</data>
<data name="DescriptionExternalEventCreated" xml:space="preserve">
<value>Событие пройдёт с {0} до {1} в {2}</value>
</data>
<data name="ButtonOpenEventInfo" xml:space="preserve">
<value>Открыть сведения о событии</value>
</data>
<data name="EventDuration" xml:space="preserve">
<value>Событие длилось `{0}`</value>
</data>
<data name="DescriptionLocalEventStarted" xml:space="preserve">
<value>Событие происходит в {0}</value>
</data>
<data name="DescriptionExternalEventStarted" xml:space="preserve">
<value>Событие происходит в {0} до {1}</value>
</data>
<data name="UserAlreadyBanned" xml:space="preserve">
<value>Этот пользователь уже забанен!</value>
</data>
<data name="UserUnbanned" xml:space="preserve">
<value>{0} был(-а) разбанен(-а)</value>
</data>
<data name="UserMuted" xml:space="preserve">
<value>{0} был(-а) заглушен(-а)</value>
</data>
<data name="UserNotMuted" xml:space="preserve">
<value>Этот участник не заглушен!</value>
</data>
<data name="UserUnmuted" xml:space="preserve">
<value>{0} был(-а) разглушен(-а)</value>
</data>
<data name="UserNotFoundShort" xml:space="preserve">
<value>Я не смог найти этого пользователя!</value>
</data>
<data name="UserKicked" xml:space="preserve">
<value>{0} был(-а) выгнан(-а)</value>
</data>
<data name="DescriptionActionReason" xml:space="preserve">
<value>Причина: {0}</value>
</data>
<data name="DescriptionActionExpiresAt" xml:space="preserve">
<value>Закончится: {0}</value>
</data>
<data name="UserAlreadyMuted" xml:space="preserve">
<value>Этот пользователь уже в муте!</value>
</data>
<data name="YouWereBanned" xml:space="preserve">
<value>Вы были забанены</value>
</data>
<data name="MessageFrom" xml:space="preserve">
<value>От {0}:</value>
</data>
<data name="AboutTitleDevelopers" xml:space="preserve">
<value>Разработчики:</value>
</data>
<data name="ButtonOpenRepository" xml:space="preserve">
<value>Исходный код Octobot</value>
</data>
<data name="AboutBot" xml:space="preserve">
<value>О боте {0}</value>
</data>
<data name="AboutDeveloper@neroduckale" xml:space="preserve">
<value>разработчик</value>
</data>
<data name="AboutDeveloper@Octol1ttle" xml:space="preserve">
<value>основной разработчик</value>
</data>
<data name="AboutDeveloper@mctaylors" xml:space="preserve">
<value>разработчик и дизайнер, создатель Octobot's Wiki</value>
</data>
<data name="ReminderCreated" xml:space="preserve">
<value>Напоминание для {0} создано</value>
</data>
<data name="Reminder" xml:space="preserve">
<value>Напоминание для {0}</value>
</data>
<data name="DescriptionReminder" xml:space="preserve">
<value>Вы просили напомнить вам {0}</value>
</data>
<data name="SettingsListTitle" xml:space="preserve">
<value>Настройки Octobot</value>
</data>
<data name="SettingSuccessfullyChanged" xml:space="preserve">
<value>Настройка успешно изменена</value>
</data>
<data name="SettingNotChanged" xml:space="preserve">
<value>Настройка не редактирована</value>
</data>
<data name="SettingIsNow" xml:space="preserve">
<value>теперь имеет значение</value>
</data>
<data name="SettingsRenameHoistedUsers" xml:space="preserve">
<value>Переименовывать участников, которые пытаются поднять себя</value>
</data>
<data name="Page" xml:space="preserve">
<value>Страница</value>
</data>
<data name="PageNotFound" xml:space="preserve">
<value>Страница не найдена!</value>
</data>
<data name="PagesAllowed" xml:space="preserve">
<value>Всего есть {0} страниц(-ы)</value>
</data>
<data name="Next" xml:space="preserve">
<value>Далее</value>
</data>
<data name="Previous" xml:space="preserve">
<value>Назад</value>
</data>
<data name="ReminderList" xml:space="preserve">
<value>Напоминания {0}</value>
</data>
<data name="InvalidReminderPosition" xml:space="preserve">
<value>У тебя нет напоминания на этой позиции!</value>
</data>
<data name="ReminderDeleted" xml:space="preserve">
<value>Напоминание удалено</value>
</data>
<data name="NoRemindersFound" xml:space="preserve">
<value>У вас нет созданных напоминаний!</value>
</data>
<data name="SingleSettingReset" xml:space="preserve">
<value>Настройка {0} сброшена</value>
</data>
<data name="AllSettingsReset" xml:space="preserve">
<value>Все настройки были сброшены</value>
</data>
<data name="DescriptionActionJumpToMessage" xml:space="preserve">
<value>Перейти к сообщению: {0}</value>
</data>
<data name="DescriptionActionJumpToChannel" xml:space="preserve">
<value>Перейти к каналу: {0}</value>
</data>
<data name="ReminderPosition" xml:space="preserve">
<value>Позиция в списке: {0}</value>
</data>
<data name="ReminderTime" xml:space="preserve">
<value>Время отправки напоминания: {0}</value>
</data>
<data name="ReminderText" xml:space="preserve">
<value>Текст напоминания: {0}</value>
</data>
<data name="UserInfoDisplayName" xml:space="preserve">
<value>Отображаемое имя</value>
</data>
<data name="InformationAbout" xml:space="preserve">
<value>Информация о {0}</value>
</data>
<data name="UserInfoMuted" xml:space="preserve">
<value>Заглушен</value>
</data>
<data name="UserInfoDiscordUserSince" xml:space="preserve">
<value>Вступил в Discord</value>
</data>
<data name="UserInfoBanned" xml:space="preserve">
<value>Забанен</value>
</data>
<data name="UserInfoPunishments" xml:space="preserve">
<value>Наказания</value>
</data>
<data name="UserInfoBannedPermanently" xml:space="preserve">
<value>Забанен навсегда</value>
</data>
<data name="UserInfoNotOnGuild" xml:space="preserve">
<value>Не на сервере</value>
</data>
<data name="UserInfoMutedByTimeout" xml:space="preserve">
<value>Заглушен с помощью тайм-аута</value>
</data>
<data name="UserInfoMutedByMuteRole" xml:space="preserve">
<value>Заглушен с помощью роли мута</value>
</data>
<data name="UserInfoGuildMemberSince" xml:space="preserve">
<value>Вступил на сервер</value>
</data>
<data name="UserInfoGuildNickname" xml:space="preserve">
<value>Никнейм</value>
</data>
<data name="UserInfoGuildRoles" xml:space="preserve">
<value>Роли</value>
</data>
<data name="UserInfoGuildMemberPremiumSince" xml:space="preserve">
<value>Начал бустить сервер</value>
</data>
<data name="RandomTitle" xml:space="preserve">
<value>Случайное число для {0}:</value>
</data>
<data name="RandomMinMaxSame" xml:space="preserve">
<value>Разве это не очевидно?</value>
</data>
<data name="RandomMax" xml:space="preserve">
<value>Максимальное число: {0}</value>
</data>
<data name="RandomMin" xml:space="preserve">
<value>Минимальное число: {0}</value>
</data>
<data name="Default" xml:space="preserve">
<value>(по умолчанию)</value>
</data>
<data name="TimestampTitle" xml:space="preserve">
<value>Временная метка для {0}:</value>
</data>
<data name="TimestampOffset" xml:space="preserve">
<value>Офсет: {0}</value>
</data>
<data name="GuildInfoDescription" xml:space="preserve">
<value>Описание сервера</value>
</data>
<data name="GuildInfoCreatedAt" xml:space="preserve">
<value>Дата создания</value>
</data>
<data name="GuildInfoOwner" xml:space="preserve">
<value>Владелец сервера</value>
</data>
<data name="GuildInfoServerBoost" xml:space="preserve">
<value>Буст сервера</value>
</data>
<data name="GuildInfoBoostTier" xml:space="preserve">
<value>Уровень буста</value>
</data>
<data name="GuildInfoBoostCount" xml:space="preserve">
<value>Количество бустов</value>
</data>
<data name="NoMessagesToClear" xml:space="preserve">
<value>Нет сообщений, которые подходят под твой фильтр!</value>
</data>
<data name="MessagesClearedFiltered" xml:space="preserve">
<value>Очищено {0} сообщений от {1}</value>
</data>
<data name="DataLoadFailedTitle" xml:space="preserve">
<value>Произошла ошибка при загрузке данных сервера.</value>
</data>
<data name="DataLoadFailedDescription" xml:space="preserve">
<value>Это может привести к неожиданному поведению. Данные больше не будут сохраняться.</value>
</data>
<data name="CommandExecutionFailed" xml:space="preserve">
<value>Произошла ошибка при выполнении команды, повтори попытку позже.</value>
</data>
<data name="ContactDevelopers" xml:space="preserve">
<value>Обратись к разработчикам, если проблема возникнет снова.</value>
</data>
<data name="ButtonReportIssue" xml:space="preserve">
<value>Сообщить о проблеме</value>
</data>
<data name="DefaultLeaveMessage" xml:space="preserve">
<value>До скорой встречи, {0}!</value>
</data>
<data name="SettingsLeaveMessage" xml:space="preserve">
<value>Сообщение о выходе</value>
</data>
<data name="InvalidTimeSpan" xml:space="preserve">
<value>Неправильно указано время!</value>
</data>
<data name="UserInfoKicked" xml:space="preserve">
<value>Выгнан</value>
</data>
<data name="ReminderEdited" xml:space="preserve">
<value>Напоминание отредактировано</value>
</data>
<data name="EightBallPositive1" xml:space="preserve">
<value>Бесспорно</value>
</data>
<data name="EightBallPositive2" xml:space="preserve">
<value>Предрешено</value>
</data>
<data name="EightBallPositive3" xml:space="preserve">
<value>Никаких сомнений</value>
</data>
<data name="EightBallPositive4" xml:space="preserve">
<value>Определённо да</value>
</data>
<data name="EightBallPositive5" xml:space="preserve">
<value>Можешь быть уверен в этом</value>
</data>
<data name="EightBallQuestionable1" xml:space="preserve">
<value>Мне кажется — «да»</value>
</data>
<data name="EightBallQuestionable2" xml:space="preserve">
<value>Вероятнее всего</value>
</data>
<data name="EightBallQuestionable3" xml:space="preserve">
<value>Хорошие перспективы</value>
</data>
<data name="EightBallQuestionable4" xml:space="preserve">
<value>Знаки говорят — «да»</value>
</data>
<data name="EightBallQuestionable5" xml:space="preserve">
<value>Да</value>
</data>
<data name="EightBallNeutral1" xml:space="preserve">
<value>Пока не ясно, попробуй снова</value>
</data>
<data name="EightBallNeutral2" xml:space="preserve">
<value>Спроси позже</value>
</data>
<data name="EightBallNeutral3" xml:space="preserve">
<value>Лучше не рассказывать</value>
</data>
<data name="EightBallNeutral4" xml:space="preserve">
<value>Сейчас нельзя предсказать</value>
</data>
<data name="EightBallNeutral5" xml:space="preserve">
<value>Сконцентрируйся и спроси снова</value>
</data>
<data name="EightBallNegative1" xml:space="preserve">
<value>Даже не думай</value>
</data>
<data name="EightBallNegative2" xml:space="preserve">
<value>Мой ответ — «нет»</value>
</data>
<data name="EightBallNegative3" xml:space="preserve">
<value>По моим данным — «нет»</value>
</data>
<data name="EightBallNegative4" xml:space="preserve">
<value>Перспективы не очень хорошие</value>
</data>
<data name="EightBallNegative5" xml:space="preserve">
<value>Весьма сомнительно</value>
</data>
<data name="TimeSpanExample" xml:space="preserve">
<value>Пример правильного ввода: `1ч30м`</value>
</data>
<data name="Version" xml:space="preserve">
<value>Версия: {0}</value>
</data>
<data name="SettingsWelcomeMessagesChannel" xml:space="preserve">
<value>Канал для приветствий</value>
</data>
<data name="ButtonDirty" xml:space="preserve">
<value>Нельзя сообщить о проблеме в версии под разработкой</value>
</data>
<data name="ButtonOpenWiki" xml:space="preserve">
<value>Открыть Octobot's Wiki</value>
</data>
<data name="SettingsModeratorRole" xml:space="preserve">
<value>Роль модератора</value>
</data>
</root>

View file

@ -0,0 +1,78 @@
using System.Globalization;
using System.Text.RegularExpressions;
using JetBrains.Annotations;
using Remora.Commands.Parsers;
using Remora.Results;
namespace TeamOctolings.Octobot.Parsers;
/// <summary>
/// Parses <see cref="TimeSpan"/>s.
/// </summary>
[PublicAPI]
public partial class TimeSpanParser : AbstractTypeParser<TimeSpan>
{
private static readonly Regex Pattern = ParseRegex();
/// <summary>
/// Parses a <see cref="TimeSpan"/> from the <paramref name="timeSpanString"/>.
/// </summary>
/// <returns>
/// The parsed <see cref="TimeSpan"/>, or <see cref="ArgumentInvalidError"/> if parsing failed.
/// </returns>
public static Result<TimeSpan> TryParse(string timeSpanString)
{
if (timeSpanString.StartsWith('-'))
{
return new ArgumentInvalidError(nameof(timeSpanString), "TimeSpans cannot be negative.");
}
if (TimeSpan.TryParse(timeSpanString, DateTimeFormatInfo.InvariantInfo, out var parsedTimeSpan))
{
return parsedTimeSpan;
}
var matches = ParseRegex().Matches(timeSpanString);
return matches.Count > 0
? ParseFromRegex(matches)
: new ArgumentInvalidError(nameof(timeSpanString), "The regex did not produce any matches.");
}
private static Result<TimeSpan> ParseFromRegex(MatchCollection matches)
{
var timeSpan = TimeSpan.Zero;
foreach (var groups in matches.Select(match => match.Groups
.Cast<Group>()
.Where(g => g.Success)
.Skip(1)
.Select(g => (g.Name, g.Value))))
{
foreach ((var key, var groupValue) in groups)
{
if (!int.TryParse(groupValue, out var parsedIntegerValue))
{
return new ArgumentInvalidError(nameof(groupValue), "The input value was not an integer.");
}
var now = DateTimeOffset.UtcNow;
timeSpan += key switch
{
"Years" => now.AddYears(parsedIntegerValue) - now,
"Months" => now.AddMonths(parsedIntegerValue) - now,
"Weeks" => TimeSpan.FromDays(parsedIntegerValue * 7),
"Days" => TimeSpan.FromDays(parsedIntegerValue),
"Hours" => TimeSpan.FromHours(parsedIntegerValue),
"Minutes" => TimeSpan.FromMinutes(parsedIntegerValue),
"Seconds" => TimeSpan.FromSeconds(parsedIntegerValue),
_ => throw new ArgumentOutOfRangeException(key)
};
}
}
return timeSpan;
}
[GeneratedRegex("(?<Years>\\d+(?=y|л|г))|(?<Months>\\d+(?=mo|мес))|(?<Weeks>\\d+(?=w|н|нед))|(?<Days>\\d+(?=d|д|дн))|(?<Hours>\\d+(?=h|ч))|(?<Minutes>\\d+(?=m|min|мин|м))|(?<Seconds>\\d+(?=s|sec|с|сек))")]
private static partial Regex ParseRegex();
}

View file

@ -0,0 +1,100 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Remora.Discord.API.Abstractions.Gateway.Commands;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.Caching.Extensions;
using Remora.Discord.Caching.Services;
using Remora.Discord.Commands.Extensions;
using Remora.Discord.Commands.Services;
using Remora.Discord.Extensions.Extensions;
using Remora.Discord.Gateway;
using Remora.Discord.Hosting.Extensions;
using Serilog.Extensions.Logging;
using TeamOctolings.Octobot.Commands.Events;
using TeamOctolings.Octobot.Services;
using TeamOctolings.Octobot.Services.Update;
namespace TeamOctolings.Octobot;
public sealed class Program
{
public static async Task Main(string[] args)
{
var host = CreateHostBuilder(args).UseConsoleLifetime().Build();
var services = host.Services;
Utility.StaticLogger = services.GetRequiredService<ILogger<Program>>();
var slashService = services.GetRequiredService<SlashService>();
// Providing a guild ID to this call will result in command duplicates!
// To get rid of them, provide the ID of the guild containing duplicates,
// comment out calls to WithCommandGroup in CreateHostBuilder
// then launch the bot again and remove the guild ID
await slashService.UpdateSlashCommandsAsync();
await host.RunAsync();
}
private static IHostBuilder CreateHostBuilder(string[] args)
{
return Host.CreateDefaultBuilder(args)
.AddDiscordService(
services =>
{
var configuration = services.GetRequiredService<IConfiguration>();
return configuration.GetValue<string?>("BOT_TOKEN")
?? throw new InvalidOperationException(
"No bot token has been provided. Set the "
+ "BOT_TOKEN environment variable to a valid token.");
}
).ConfigureServices(
(_, services) =>
{
services.Configure<DiscordGatewayClientOptions>(
options =>
{
options.Intents |= GatewayIntents.MessageContents
| GatewayIntents.GuildMembers
| GatewayIntents.GuildPresences
| GatewayIntents.GuildScheduledEvents;
});
services.Configure<CacheSettings>(
cSettings =>
{
cSettings.SetDefaultAbsoluteExpiration(TimeSpan.FromHours(1));
cSettings.SetDefaultSlidingExpiration(TimeSpan.FromMinutes(30));
cSettings.SetAbsoluteExpiration<IMessage>(TimeSpan.FromDays(7));
cSettings.SetSlidingExpiration<IMessage>(TimeSpan.FromDays(7));
});
services.AddTransient<IConfigurationBuilder, ConfigurationBuilder>()
// Init
.AddDiscordCaching()
.AddDiscordCommands(true, false)
.AddRespondersFromAssembly(typeof(Program).Assembly)
.AddCommandGroupsFromAssembly(typeof(Program).Assembly)
// Slash command event handlers
.AddPreparationErrorEvent<LoggingPreparationErrorEvent>()
.AddPostExecutionEvent<ErrorLoggingPostExecutionEvent>()
// Services
.AddSingleton<AccessControlService>()
.AddSingleton<GuildDataService>()
.AddSingleton<Utility>()
.AddHostedService<GuildDataService>(provider => provider.GetRequiredService<GuildDataService>())
.AddHostedService<MemberUpdateService>()
.AddHostedService<ScheduledEventUpdateService>()
.AddHostedService<SongUpdateService>();
}
).ConfigureLogging(
c => c.AddConsole()
.AddFile("Logs/Octobot-{Date}.log",
outputTemplate: "{Timestamp:o} [{Level:u4}] {Message} {NewLine}{Exception}")
.AddFilter("System.Net.Http.HttpClient.*.LogicalHandler", LogLevel.Warning)
.AddFilter("System.Net.Http.HttpClient.*.ClientHandler", LogLevel.Warning)
.AddFilter<SerilogLoggerProvider>("System.Net.Http.HttpClient.*.LogicalHandler", LogLevel.Warning)
.AddFilter<SerilogLoggerProvider>("System.Net.Http.HttpClient.*.ClientHandler", LogLevel.Warning)
);
}
}

View file

@ -0,0 +1,125 @@
using JetBrains.Annotations;
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.API.Gateway.Events;
using Remora.Discord.API.Objects;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway.Responders;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace TeamOctolings.Octobot.Responders;
/// <summary>
/// Handles sending a <see cref="Ready" /> message to a guild that has just initialized if that guild
/// has <see cref="GuildSettings.ReceiveStartupMessages" /> enabled
/// </summary>
[UsedImplicitly]
public class GuildLoadedResponder : IResponder<IGuildCreate>
{
private readonly IDiscordRestChannelAPI _channelApi;
private readonly GuildDataService _guildData;
private readonly ILogger<GuildLoadedResponder> _logger;
private readonly IDiscordRestUserAPI _userApi;
private readonly Utility _utility;
public GuildLoadedResponder(
IDiscordRestChannelAPI channelApi, GuildDataService guildData, ILogger<GuildLoadedResponder> logger,
IDiscordRestUserAPI userApi, Utility utility)
{
_channelApi = channelApi;
_guildData = guildData;
_logger = logger;
_userApi = userApi;
_utility = utility;
}
public async Task<Result> RespondAsync(IGuildCreate gatewayEvent, CancellationToken ct = default)
{
if (!gatewayEvent.Guild.IsT0) // Guild is not IAvailableGuild
{
return Result.Success;
}
var guild = gatewayEvent.Guild.AsT0;
var data = await _guildData.GetData(guild.ID, ct);
var cfg = data.Settings;
foreach (var member in guild.Members.Where(m => m.User.HasValue))
{
data.GetOrCreateMemberData(member.User.Value.ID);
}
var botResult = await _userApi.GetCurrentUserAsync(ct);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
if (data.DataLoadFailed)
{
return await SendDataLoadFailed(guild, data, bot, ct);
}
var ownerResult = await _userApi.GetUserAsync(guild.OwnerID, ct);
if (!ownerResult.IsDefined(out var owner))
{
return ResultExtensions.FromError(ownerResult);
}
_logger.LogInformation("Loaded guild \"{Name}\" ({ID}) owned by {Owner} ({OwnerID}) with {MemberCount} members",
guild.Name, guild.ID, owner.GetTag(), owner.ID, guild.MemberCount);
if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()
|| !GuildSettings.ReceiveStartupMessages.Get(cfg))
{
return Result.Success;
}
Messages.Culture = GuildSettings.Language.Get(cfg);
var i = Random.Shared.Next(1, 4);
var embed = new EmbedBuilder().WithSmallTitle(bot.GetTag(), bot)
.WithTitle($"Generic{i}".Localized())
.WithDescription(Messages.Ready)
.WithCurrentTimestamp()
.WithColour(ColorsList.Blue)
.Build();
return await _channelApi.CreateMessageWithEmbedResultAsync(
GuildSettings.PrivateFeedbackChannel.Get(cfg), embedResult: embed, ct: ct);
}
private async Task<Result> SendDataLoadFailed(IGuild guild, GuildData data, IUser bot, CancellationToken ct)
{
var channelResult = await _utility.GetEmergencyFeedbackChannel(guild, data, ct);
if (!channelResult.IsDefined(out var channel))
{
return ResultExtensions.FromError(channelResult);
}
var errorEmbed = new EmbedBuilder()
.WithSmallTitle(Messages.DataLoadFailedTitle, bot)
.WithDescription(Messages.DataLoadFailedDescription)
.WithFooter(Messages.ContactDevelopers)
.WithColour(ColorsList.Red)
.Build();
var issuesButton = new ButtonComponent(
ButtonComponentStyle.Link,
BuildInfo.IsDirty
? Messages.ButtonDirty
: Messages.ButtonReportIssue,
new PartialEmoji(Name: "\u26a0\ufe0f"), // 'WARNING SIGN' (U+26A0)
URL: BuildInfo.IssuesUrl,
IsDisabled: BuildInfo.IsDirty
);
return await _channelApi.CreateMessageWithEmbedResultAsync(channel, embedResult: errorEmbed,
components: new[] { new ActionRowComponent(new[] { issuesButton }) }, ct: ct);
}
}

View file

@ -0,0 +1,106 @@
using System.Text.Json.Nodes;
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.Rest.Core;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace TeamOctolings.Octobot.Responders;
/// <summary>
/// Handles sending a guild's <see cref="GuildSettings.WelcomeMessage" /> if one is set.
/// If <see cref="GuildSettings.ReturnRolesOnRejoin" /> is enabled, roles will be returned.
/// </summary>
/// <seealso cref="GuildSettings.WelcomeMessage" />
[UsedImplicitly]
public class GuildMemberJoinedResponder : IResponder<IGuildMemberAdd>
{
private readonly IDiscordRestChannelAPI _channelApi;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly GuildDataService _guildData;
public GuildMemberJoinedResponder(
IDiscordRestChannelAPI channelApi, GuildDataService guildData, IDiscordRestGuildAPI guildApi)
{
_channelApi = channelApi;
_guildData = guildData;
_guildApi = guildApi;
}
public async Task<Result> RespondAsync(IGuildMemberAdd gatewayEvent, CancellationToken ct = default)
{
if (!gatewayEvent.User.IsDefined(out var user))
{
return new ArgumentNullError(nameof(gatewayEvent.User));
}
var data = await _guildData.GetData(gatewayEvent.GuildID, ct);
var cfg = data.Settings;
var memberData = data.GetOrCreateMemberData(user.ID);
memberData.Kicked = false;
var returnRolesResult = await TryReturnRolesAsync(cfg, memberData, gatewayEvent.GuildID, user.ID, ct);
if (!returnRolesResult.IsSuccess)
{
return ResultExtensions.FromError(returnRolesResult);
}
if (GuildSettings.WelcomeMessagesChannel.Get(cfg).Empty()
|| GuildSettings.WelcomeMessage.Get(cfg) is "off" or "disable" or "disabled")
{
return Result.Success;
}
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 ResultExtensions.FromError(guildResult);
}
var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(welcomeMessage, user.GetTag(), guild.Name), user)
.WithGuildFooter(guild)
.WithTimestamp(gatewayEvent.JoinedAt)
.WithColour(ColorsList.Green)
.Build();
return await _channelApi.CreateMessageWithEmbedResultAsync(
GuildSettings.WelcomeMessagesChannel.Get(cfg), embedResult: embed,
allowedMentions: Utility.NoMentions, ct: ct);
}
private async Task<Result> TryReturnRolesAsync(
JsonNode cfg, MemberData memberData, Snowflake guildId, Snowflake userId, CancellationToken ct)
{
if (!GuildSettings.ReturnRolesOnRejoin.Get(cfg))
{
return Result.Success;
}
var assignRoles = new List<Snowflake>();
if (memberData.MutedUntil is null || !GuildSettings.RemoveRolesOnMute.Get(cfg))
{
assignRoles.AddRange(memberData.Roles.ConvertAll(r => r.ToSnowflake()));
}
if (memberData.MutedUntil is not null)
{
assignRoles.Add(GuildSettings.MuteRole.Get(cfg));
}
return await _guildApi.ModifyGuildMemberAsync(
guildId, userId, roles: assignRoles, ct: ct);
}
}

View file

@ -0,0 +1,72 @@
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;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace TeamOctolings.Octobot.Responders;
/// <summary>
/// Handles sending a guild's <see cref="GuildSettings.LeaveMessage" /> if one is set.
/// </summary>
/// <seealso cref="GuildSettings.LeaveMessage" />
[UsedImplicitly]
public class GuildMemberLeftResponder : IResponder<IGuildMemberRemove>
{
private readonly IDiscordRestChannelAPI _channelApi;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly GuildDataService _guildData;
public GuildMemberLeftResponder(
IDiscordRestChannelAPI channelApi, GuildDataService guildData, IDiscordRestGuildAPI guildApi)
{
_channelApi = channelApi;
_guildData = guildData;
_guildApi = guildApi;
}
public async Task<Result> RespondAsync(IGuildMemberRemove gatewayEvent, CancellationToken ct = default)
{
var user = gatewayEvent.User;
var data = await _guildData.GetData(gatewayEvent.GuildID, ct);
var cfg = data.Settings;
var memberData = data.GetOrCreateMemberData(user.ID);
if (memberData.BannedUntil is not null || memberData.Kicked)
{
return Result.Success;
}
if (GuildSettings.WelcomeMessagesChannel.Get(cfg).Empty()
|| GuildSettings.LeaveMessage.Get(cfg) is "off" or "disable" or "disabled")
{
return Result.Success;
}
Messages.Culture = GuildSettings.Language.Get(cfg);
var leaveMessage = GuildSettings.LeaveMessage.Get(cfg) is "default" or "reset"
? Messages.DefaultLeaveMessage
: GuildSettings.LeaveMessage.Get(cfg);
var guildResult = await _guildApi.GetGuildAsync(gatewayEvent.GuildID, ct: ct);
if (!guildResult.IsDefined(out var guild))
{
return ResultExtensions.FromError(guildResult);
}
var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(leaveMessage, user.GetTag(), guild.Name), user)
.WithGuildFooter(guild)
.WithTimestamp(DateTimeOffset.UtcNow)
.WithColour(ColorsList.Black)
.Build();
return await _channelApi.CreateMessageWithEmbedResultAsync(
GuildSettings.WelcomeMessagesChannel.Get(cfg), embedResult: embed,
allowedMentions: Utility.NoMentions, ct: ct);
}
}

View file

@ -0,0 +1,38 @@
using JetBrains.Annotations;
using Microsoft.Extensions.Logging;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.Gateway.Responders;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Services;
namespace TeamOctolings.Octobot.Responders;
/// <summary>
/// Handles removing guild ID from <see cref="GuildData" /> if the guild becomes unavailable.
/// </summary>
[UsedImplicitly]
public class GuildUnloadedResponder : IResponder<IGuildDelete>
{
private readonly GuildDataService _guildData;
private readonly ILogger<GuildUnloadedResponder> _logger;
public GuildUnloadedResponder(
GuildDataService guildData, ILogger<GuildUnloadedResponder> logger)
{
_guildData = guildData;
_logger = logger;
}
public Task<Result> RespondAsync(IGuildDelete gatewayEvent, CancellationToken ct = default)
{
var guildId = gatewayEvent.ID;
var isDataRemoved = _guildData.UnloadGuildData(guildId);
if (isDataRemoved)
{
_logger.LogInformation("Unloaded guild {GuildId}", guildId);
}
return Task.FromResult(Result.Success);
}
}

View file

@ -0,0 +1,107 @@
using System.Text;
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;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace TeamOctolings.Octobot.Responders;
/// <summary>
/// Handles logging the contents of a deleted message and the user who deleted the message
/// to a guild's <see cref="GuildSettings.PrivateFeedbackChannel" /> if one is set.
/// </summary>
[UsedImplicitly]
public class MessageDeletedResponder : IResponder<IMessageDelete>
{
private readonly IDiscordRestAuditLogAPI _auditLogApi;
private readonly IDiscordRestChannelAPI _channelApi;
private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi;
public MessageDeletedResponder(
IDiscordRestAuditLogAPI auditLogApi, IDiscordRestChannelAPI channelApi,
GuildDataService guildData, IDiscordRestUserAPI userApi)
{
_auditLogApi = auditLogApi;
_channelApi = channelApi;
_guildData = guildData;
_userApi = userApi;
}
public async Task<Result> RespondAsync(IMessageDelete gatewayEvent, CancellationToken ct = default)
{
if (!gatewayEvent.GuildID.IsDefined(out var guildId))
{
return Result.Success;
}
var cfg = await _guildData.GetSettings(guildId, ct);
if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty())
{
return Result.Success;
}
var messageResult = await _channelApi.GetChannelMessageAsync(gatewayEvent.ChannelID, gatewayEvent.ID, ct);
if (!messageResult.IsDefined(out var message))
{
return ResultExtensions.FromError(messageResult);
}
if (string.IsNullOrWhiteSpace(message.Content))
{
return Result.Success;
}
var auditLogResult = await _auditLogApi.GetGuildAuditLogAsync(
guildId, actionType: AuditLogEvent.MessageDelete, limit: 1, ct: ct);
if (!auditLogResult.IsDefined(out var auditLogPage))
{
return ResultExtensions.FromError(auditLogResult);
}
var auditLog = auditLogPage.AuditLogEntries.Single();
var deleterResult = Result<IUser>.FromSuccess(message.Author);
if (auditLog.UserID is not null
&& auditLog.Options.Value.ChannelID == gatewayEvent.ChannelID
&& DateTimeOffset.UtcNow.Subtract(auditLog.ID.Timestamp).TotalSeconds <= 2)
{
deleterResult = await _userApi.GetUserAsync(auditLog.UserID.Value, ct);
}
if (!deleterResult.IsDefined(out var deleter))
{
return ResultExtensions.FromError(deleterResult);
}
Messages.Culture = GuildSettings.Language.Get(cfg);
var builder = new StringBuilder()
.AppendLine(message.Content.InBlockCode())
.AppendLine(
string.Format(Messages.DescriptionActionJumpToChannel, Mention.Channel(gatewayEvent.ChannelID))
);
var embed = new EmbedBuilder()
.WithSmallTitle(
string.Format(
Messages.CachedMessageDeleted,
message.Author.GetTag()), message.Author)
.WithDescription(builder.ToString())
.WithActionFooter(deleter)
.WithTimestamp(message.Timestamp)
.WithColour(ColorsList.Red)
.Build();
return await _channelApi.CreateMessageWithEmbedResultAsync(
GuildSettings.PrivateFeedbackChannel.Get(cfg), embedResult: embed,
allowedMentions: Utility.NoMentions, ct: ct);
}
}

View file

@ -0,0 +1,109 @@
using System.Text;
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;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace TeamOctolings.Octobot.Responders;
/// <summary>
/// Handles logging the difference between an edited message's old and new content
/// to a guild's <see cref="GuildSettings.PrivateFeedbackChannel" /> if one is set.
/// </summary>
[UsedImplicitly]
public class MessageEditedResponder : IResponder<IMessageUpdate>
{
private readonly CacheService _cacheService;
private readonly IDiscordRestChannelAPI _channelApi;
private readonly GuildDataService _guildData;
public MessageEditedResponder(
CacheService cacheService, IDiscordRestChannelAPI channelApi, GuildDataService guildData)
{
_cacheService = cacheService;
_channelApi = channelApi;
_guildData = guildData;
}
public async Task<Result> RespondAsync(IMessageUpdate gatewayEvent, CancellationToken ct = default)
{
if (!gatewayEvent.ID.IsDefined(out var messageId))
{
return new ArgumentNullError(nameof(gatewayEvent.ID));
}
if (!gatewayEvent.ChannelID.IsDefined(out var channelId))
{
return new ArgumentNullError(nameof(gatewayEvent.ChannelID));
}
if (!gatewayEvent.GuildID.IsDefined(out var guildId)
|| !gatewayEvent.Author.IsDefined(out var author)
|| !gatewayEvent.EditedTimestamp.IsDefined(out var timestamp)
|| !gatewayEvent.Content.IsDefined(out var newContent))
{
return Result.Success;
}
var cfg = await _guildData.GetSettings(guildId, ct);
if (author.IsBot.OrDefault(false) || GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty())
{
return Result.Success;
}
var cacheKey = new KeyHelpers.MessageCacheKey(channelId, messageId);
var messageResult = await _cacheService.TryGetValueAsync<IMessage>(
cacheKey, ct);
if (!messageResult.IsDefined(out var message))
{
_ = _channelApi.GetChannelMessageAsync(channelId, messageId, ct);
return Result.Success;
}
if (message.Content == newContent)
{
return Result.Success;
}
// 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<IMessage>(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 diff = InlineDiffBuilder.Diff(message.Content, newContent);
Messages.Culture = GuildSettings.Language.Get(cfg);
var builder = new StringBuilder()
.AppendLine(diff.AsMarkdown())
.AppendLine(string.Format(Messages.DescriptionActionJumpToMessage,
$"https://discord.com/channels/{guildId}/{channelId}/{messageId}")
);
var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(Messages.CachedMessageEdited, message.Author.GetTag()), message.Author)
.WithDescription(builder.ToString())
.WithTimestamp(timestamp.Value)
.WithColour(ColorsList.Yellow)
.Build();
return await _channelApi.CreateMessageWithEmbedResultAsync(
GuildSettings.PrivateFeedbackChannel.Get(cfg), embedResult: embed,
allowedMentions: Utility.NoMentions, ct: ct);
}
}

View file

@ -0,0 +1,39 @@
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 TeamOctolings.Octobot.Responders;
/// <summary>
/// Handles sending replies to easter egg messages.
/// </summary>
[UsedImplicitly]
public class MessageCreateResponder : IResponder<IMessageCreate>
{
private readonly IDiscordRestChannelAPI _channelApi;
public MessageCreateResponder(IDiscordRestChannelAPI channelApi)
{
_channelApi = channelApi;
}
public Task<Result> RespondAsync(IMessageCreate gatewayEvent, CancellationToken ct = default)
{
_ = _channelApi.CreateMessageAsync(
gatewayEvent.ChannelID, ct: ct, content: gatewayEvent.Content.ToLowerInvariant() switch
{
"whoami" => "`nobody`",
"сука !!" => "`root`",
"воооо" => "`removing /...`",
"пон" => "https://i.ibb.co/Kw6QVcw/parry.jpg",
"++++" => "#",
"осу" => "https://github.com/ppy/osu",
"лан" => "https://i.ibb.co/VYH2QLc/lan.jpg",
_ => default(Optional<string>)
});
return Task.FromResult(Result.Success);
}
}

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;
}
}

View file

@ -0,0 +1,46 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>2.0.0</Version>
<Title>Octobot</Title>
<Authors>Octol1ttle, mctaylors, neroduckale</Authors>
<Copyright>AGPLv3</Copyright>
<PackageProjectUrl>https://github.com/TeamOctolings/Octobot</PackageProjectUrl>
<PackageLicenseUrl>https://github.com/TeamOctolings/Octobot/blob/master/LICENSE</PackageLicenseUrl>
<RepositoryUrl>https://github.com/TeamOctolings/Octobot</RepositoryUrl>
<RepositoryType>github</RepositoryType>
<Company>TeamOctolings</Company>
<NeutralLanguage>en</NeutralLanguage>
<Description>A general-purpose Discord bot for moderation written in C#</Description>
<ApplicationIcon>../docs/octobot.ico</ApplicationIcon>
<GitVersion>false</GitVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DiffPlex" Version="1.7.2" />
<PackageReference Include="GitInfo" Version="3.3.4" />
<PackageReference Include="Humanizer.Core.ru" Version="2.14.1" />
<PackageReference Include="JetBrains.Annotations" Version="2023.3.0" />
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Remora.Commands" Version="10.0.5" />
<PackageReference Include="Remora.Discord.Caching" Version="38.0.1" />
<PackageReference Include="Remora.Discord.Extensions" Version="5.3.4" />
<PackageReference Include="Remora.Discord.Hosting" Version="6.0.9" />
<PackageReference Include="Remora.Discord.Interactivity" Version="4.5.3" />
<PackageReference Include="Serilog.Extensions.Logging.File" Version="3.0.0" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Messages.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Messages.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="..\CodeAnalysis\BannedSymbols.txt" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,153 @@
using System.Drawing;
using System.Text;
using System.Text.Json.Nodes;
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.Attributes;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
namespace TeamOctolings.Octobot;
/// <summary>
/// Provides utility methods that cannot be transformed to extension methods because they require usage
/// of some Discord APIs.
/// </summary>
public sealed class Utility
{
public static readonly AllowedMentions NoMentions = new(
Array.Empty<MentionType>(), Array.Empty<Snowflake>(), Array.Empty<Snowflake>());
private readonly IDiscordRestChannelAPI _channelApi;
private readonly IDiscordRestGuildScheduledEventAPI _eventApi;
private readonly IDiscordRestGuildAPI _guildApi;
public Utility(
IDiscordRestChannelAPI channelApi, IDiscordRestGuildScheduledEventAPI eventApi, IDiscordRestGuildAPI guildApi)
{
_channelApi = channelApi;
_eventApi = eventApi;
_guildApi = guildApi;
}
[StaticCallersOnly]
public static ILogger<Program>? StaticLogger { get; set; }
/// <summary>
/// Gets the string mentioning the <see cref="GuildSettings.EventNotificationRole" /> and event subscribers related to
/// a scheduled
/// event.
/// </summary>
/// <param name="scheduledEvent">
/// The scheduled event whose subscribers will be mentioned.
/// </param>
/// <param name="data">The data of the guild containing the scheduled event.</param>
/// <param name="ct">The cancellation token for this operation.</param>
/// <returns>A result containing the string which may or may not have succeeded.</returns>
public async Task<Result<string>> GetEventNotificationMentions(
IGuildScheduledEvent scheduledEvent, GuildData data, CancellationToken ct = default)
{
var builder = new StringBuilder();
var role = GuildSettings.EventNotificationRole.Get(data.Settings);
var subscribersResult = await _eventApi.GetGuildScheduledEventUsersAsync(
scheduledEvent.GuildID, scheduledEvent.ID, ct: ct);
if (!subscribersResult.IsDefined(out var subscribers))
{
return Result<string>.FromError(subscribersResult);
}
if (!role.Empty())
{
builder.Append($"{Mention.Role(role)} ");
}
builder = subscribers.Where(
subscriber => !data.GetOrCreateMemberData(subscriber.User.ID).Roles.Contains(role.Value))
.Aggregate(builder, (current, subscriber) => current.Append($"{Mention.User(subscriber.User)} "));
return builder.ToString();
}
/// <summary>
/// Logs an action in the <see cref="GuildSettings.PublicFeedbackChannel" /> and
/// <see cref="GuildSettings.PrivateFeedbackChannel" />.
/// </summary>
/// <param name="cfg">The guild configuration.</param>
/// <param name="channelId">The ID of the channel where the action was executed.</param>
/// <param name="user">The user who performed the action.</param>
/// <param name="title">The title for the embed.</param>
/// <param name="description">The description of the embed.</param>
/// <param name="avatar">The user whose avatar will be displayed next to the <paramref name="title" /> of the embed.</param>
/// <param name="color">The color of the embed.</param>
/// <param name="isPublic">
/// Whether or not the embed should be sent in <see cref="GuildSettings.PublicFeedbackChannel" />
/// </param>
/// <param name="ct">The cancellation token for this operation.</param>
/// <returns>A result which has succeeded.</returns>
public void LogAction(
JsonNode cfg, Snowflake channelId, IUser user, string title, string description, IUser avatar,
Color color, bool isPublic = true, CancellationToken ct = default)
{
var publicChannel = GuildSettings.PublicFeedbackChannel.Get(cfg);
var privateChannel = GuildSettings.PrivateFeedbackChannel.Get(cfg);
if (GuildSettings.PublicFeedbackChannel.Get(cfg).EmptyOrEqualTo(channelId)
&& GuildSettings.PrivateFeedbackChannel.Get(cfg).EmptyOrEqualTo(channelId))
{
return;
}
var logEmbed = new EmbedBuilder().WithSmallTitle(title, avatar)
.WithDescription(description)
.WithActionFooter(user)
.WithCurrentTimestamp()
.WithColour(color)
.Build();
// Not awaiting to reduce response time
if (isPublic && publicChannel != channelId)
{
_ = _channelApi.CreateMessageWithEmbedResultAsync(
publicChannel, embedResult: logEmbed,
ct: ct);
}
if (privateChannel != publicChannel
&& privateChannel != channelId)
{
_ = _channelApi.CreateMessageWithEmbedResultAsync(
privateChannel, embedResult: logEmbed,
ct: ct);
}
}
public async Task<Result<Snowflake>> GetEmergencyFeedbackChannel(IGuild guild, GuildData data, CancellationToken ct)
{
var privateFeedback = GuildSettings.PrivateFeedbackChannel.Get(data.Settings);
if (!privateFeedback.Empty())
{
return privateFeedback;
}
var publicFeedback = GuildSettings.PublicFeedbackChannel.Get(data.Settings);
if (!publicFeedback.Empty())
{
return publicFeedback;
}
if (guild.SystemChannelID.AsOptional().IsDefined(out var systemChannel))
{
return systemChannel;
}
var channelsResult = await _guildApi.GetGuildChannelsAsync(guild.ID, ct);
return channelsResult.IsDefined(out var channels)
? channels[0].ID
: Result<Snowflake>.FromError(channelsResult);
}
}