1
0
Fork 1
mirror of https://github.com/TeamOctolings/Octobot.git synced 2025-04-19 16:33:36 +03:00

Merge branch 'refs/heads/master' into another-songlist-update

This commit is contained in:
Macintxsh 2024-08-22 20:57:36 +05:00
commit 4386d5fed4
Signed by: mctaylors
GPG key ID: 4EEF4F949A266EE2
67 changed files with 1618 additions and 1777 deletions

View file

@ -42,7 +42,7 @@ csharp_space_between_square_brackets = false
csharp_style_expression_bodied_accessors = false:warning csharp_style_expression_bodied_accessors = false:warning
csharp_style_expression_bodied_constructors = false:warning csharp_style_expression_bodied_constructors = false:warning
csharp_style_expression_bodied_methods = false:warning csharp_style_expression_bodied_methods = false:warning
csharp_style_expression_bodied_properties = false:warning csharp_style_expression_bodied_properties = true:warning
csharp_style_namespace_declarations = file_scoped:warning csharp_style_namespace_declarations = file_scoped:warning
csharp_style_prefer_utf8_string_literals = true:warning csharp_style_prefer_utf8_string_literals = true:warning
csharp_style_var_elsewhere = true:warning csharp_style_var_elsewhere = true:warning

View file

@ -15,6 +15,10 @@ updates:
labels: labels:
- "type: change" - "type: change"
- "area: build/ci" - "area: build/ci"
# For all packages, ignore all patch updates
ignore:
- dependency-name: "*"
update-types: [ "version-update:semver-patch" ]
- package-ecosystem: "nuget" # See documentation for possible values - package-ecosystem: "nuget" # See documentation for possible values
directory: "/" # Location of package manifests directory: "/" # Location of package manifests
@ -30,3 +34,7 @@ updates:
remora: remora:
patterns: patterns:
- "Remora.Discord.*" - "Remora.Discord.*"
# For all packages, ignore all patch updates
ignore:
- dependency-name: "*"
update-types: [ "version-update:semver-patch" ]

View file

@ -22,8 +22,13 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.301'
- name: ReSharper CLI InspectCode - name: ReSharper CLI InspectCode
uses: muno92/resharper_inspectcode@1.11.7 uses: muno92/resharper_inspectcode@1.11.12
with: with:
solutionPath: ./Octobot.sln solutionPath: ./Octobot.sln
ignoreIssueType: InvertIf, ConvertIfStatementToSwitchStatement, ConvertToPrimaryConstructor ignoreIssueType: InvertIf, ConvertIfStatementToSwitchStatement, ConvertToPrimaryConstructor

View file

@ -1,6 +1,6 @@
 
Microsoft Visual Studio Solution File, Format Version 12.00 Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Octobot", "Octobot.csproj", "{9CA7A44F-167C-46D4-923D-88CE71044144}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamOctolings.Octobot", "TeamOctolings.Octobot\TeamOctolings.Octobot.csproj", "{A1679BA2-3A36-4D98-80C0-EEE771398FBD}"
EndProject EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
@ -8,9 +8,9 @@ Global
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{9CA7A44F-167C-46D4-923D-88CE71044144}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A1679BA2-3A36-4D98-80C0-EEE771398FBD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9CA7A44F-167C-46D4-923D-88CE71044144}.Debug|Any CPU.Build.0 = Debug|Any CPU {A1679BA2-3A36-4D98-80C0-EEE771398FBD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9CA7A44F-167C-46D4-923D-88CE71044144}.Release|Any CPU.ActiveCfg = Release|Any CPU {A1679BA2-3A36-4D98-80C0-EEE771398FBD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9CA7A44F-167C-46D4-923D-88CE71044144}.Release|Any CPU.Build.0 = Release|Any CPU {A1679BA2-3A36-4D98-80C0-EEE771398FBD}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

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

@ -1,6 +1,6 @@
using System.Drawing; using System.Drawing;
namespace Octobot; namespace TeamOctolings.Octobot;
/// <summary> /// <summary>
/// Contains all colors used in embeds. /// Contains all colors used in embeds.

View file

@ -1,9 +1,6 @@
using System.ComponentModel; using System.ComponentModel;
using System.Text; using System.Text;
using JetBrains.Annotations; using JetBrains.Annotations;
using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services;
using Remora.Commands.Attributes; using Remora.Commands.Attributes;
using Remora.Commands.Groups; using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
@ -18,14 +15,17 @@ using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting; using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core; using Remora.Rest.Core;
using Remora.Results; using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace Octobot.Commands; namespace TeamOctolings.Octobot.Commands;
/// <summary> /// <summary>
/// Handles the command to show information about this bot: /about. /// Handles the command to show information about this bot: /about.
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class AboutCommandGroup : CommandGroup public sealed class AboutCommandGroup : CommandGroup
{ {
private static readonly (string Username, Snowflake Id)[] Developers = private static readonly (string Username, Snowflake Id)[] Developers =
[ [
@ -36,9 +36,9 @@ public class AboutCommandGroup : CommandGroup
private readonly ICommandContext _context; private readonly ICommandContext _context;
private readonly IFeedbackService _feedback; private readonly IFeedbackService _feedback;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly GuildDataService _guildData; private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi; private readonly IDiscordRestUserAPI _userApi;
private readonly IDiscordRestGuildAPI _guildApi;
public AboutCommandGroup( public AboutCommandGroup(
ICommandContext context, GuildDataService guildData, ICommandContext context, GuildDataService guildData,
@ -73,7 +73,7 @@ public class AboutCommandGroup : CommandGroup
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot)) if (!botResult.IsDefined(out var bot))
{ {
return Result.FromError(botResult); return ResultExtensions.FromError(botResult);
} }
var cfg = await _guildData.GetSettings(guildId, CancellationToken); var cfg = await _guildData.GetSettings(guildId, CancellationToken);
@ -100,27 +100,38 @@ public class AboutCommandGroup : CommandGroup
.WithSmallTitle(string.Format(Messages.AboutBot, bot.Username), bot) .WithSmallTitle(string.Format(Messages.AboutBot, bot.Username), bot)
.WithDescription(builder.ToString()) .WithDescription(builder.ToString())
.WithColour(ColorsList.Cyan) .WithColour(ColorsList.Cyan)
.WithImageUrl("https://i.ibb.co/fS6wZhh/octobot-banner.png") .WithImageUrl("https://raw.githubusercontent.com/TeamOctolings/Octobot/HEAD/docs/octobot-banner.png")
.WithFooter(string.Format(Messages.Version, BuildInfo.Version))
.Build(); .Build();
var repositoryButton = new ButtonComponent( var repositoryButton = new ButtonComponent(
ButtonComponentStyle.Link, ButtonComponentStyle.Link,
Messages.ButtonOpenRepository, Messages.ButtonOpenRepository,
new PartialEmoji(Name: "🌐"), new PartialEmoji(Name: "\ud83c\udf10"), // 'GLOBE WITH MERIDIANS' (U+1F310)
URL: Octobot.RepositoryUrl 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( var issuesButton = new ButtonComponent(
ButtonComponentStyle.Link, ButtonComponentStyle.Link,
Messages.ButtonReportIssue, BuildInfo.IsDirty
new PartialEmoji(Name: "⚠️"), ? Messages.ButtonDirty
URL: Octobot.IssuesUrl : Messages.ButtonReportIssue,
new PartialEmoji(Name: "\u26a0\ufe0f"), // 'WARNING SIGN' (U+26A0)
URL: BuildInfo.IssuesUrl,
IsDisabled: BuildInfo.IsDirty
); );
return await _feedback.SendContextualEmbedResultAsync(embed, return await _feedback.SendContextualEmbedResultAsync(embed,
new FeedbackMessageOptions(MessageComponents: new[] new FeedbackMessageOptions(MessageComponents: new[]
{ {
new ActionRowComponent(new[] { repositoryButton, issuesButton }) new ActionRowComponent(new[] { repositoryButton, wikiButton, issuesButton })
}), ct); }), ct);
} }
} }

View file

@ -2,11 +2,6 @@ using System.ComponentModel;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Text; using System.Text;
using JetBrains.Annotations; using JetBrains.Annotations;
using Octobot.Data;
using Octobot.Extensions;
using Octobot.Parsers;
using Octobot.Services;
using Octobot.Services.Update;
using Remora.Commands.Attributes; using Remora.Commands.Attributes;
using Remora.Commands.Groups; using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
@ -19,15 +14,21 @@ using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting; using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core; using Remora.Rest.Core;
using Remora.Results; 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 Octobot.Commands; namespace TeamOctolings.Octobot.Commands;
/// <summary> /// <summary>
/// Handles commands related to ban management: /ban and /unban. /// Handles commands related to ban management: /ban and /unban.
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class BanCommandGroup : CommandGroup public sealed class BanCommandGroup : CommandGroup
{ {
private readonly AccessControlService _access;
private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestChannelAPI _channelApi;
private readonly ICommandContext _context; private readonly ICommandContext _context;
private readonly IFeedbackService _feedback; private readonly IFeedbackService _feedback;
@ -36,16 +37,16 @@ public class BanCommandGroup : CommandGroup
private readonly IDiscordRestUserAPI _userApi; private readonly IDiscordRestUserAPI _userApi;
private readonly Utility _utility; private readonly Utility _utility;
public BanCommandGroup( public BanCommandGroup(AccessControlService access, IDiscordRestChannelAPI channelApi, ICommandContext context,
ICommandContext context, IDiscordRestChannelAPI channelApi, GuildDataService guildData, IFeedbackService feedback, IDiscordRestGuildAPI guildApi, GuildDataService guildData,
IFeedbackService feedback, IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi, IDiscordRestUserAPI userApi, Utility utility)
Utility utility)
{ {
_context = context; _access = access;
_channelApi = channelApi; _channelApi = channelApi;
_guildData = guildData; _context = context;
_feedback = feedback; _feedback = feedback;
_guildApi = guildApi; _guildApi = guildApi;
_guildData = guildData;
_userApi = userApi; _userApi = userApi;
_utility = utility; _utility = utility;
} }
@ -65,10 +66,10 @@ public class BanCommandGroup : CommandGroup
/// </returns> /// </returns>
/// <seealso cref="ExecuteUnban" /> /// <seealso cref="ExecuteUnban" />
[Command("ban", "бан")] [Command("ban", "бан")]
[DiscordDefaultMemberPermissions(DiscordPermission.BanMembers)] [DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)]
[DiscordDefaultDMPermission(false)] [DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)] [RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.BanMembers)] [RequireDiscordPermission(DiscordPermission.ManageMessages)]
[RequireBotDiscordPermissions(DiscordPermission.BanMembers)] [RequireBotDiscordPermissions(DiscordPermission.BanMembers)]
[Description("Ban user")] [Description("Ban user")]
[UsedImplicitly] [UsedImplicitly]
@ -76,7 +77,8 @@ public class BanCommandGroup : CommandGroup
[Description("User to ban")] IUser target, [Description("User to ban")] IUser target,
[Description("Ban reason")] [MaxLength(256)] [Description("Ban reason")] [MaxLength(256)]
string reason, string reason,
[Description("Ban duration")] string? duration = null) [Description("Ban duration (e.g. 1h30m)")]
string? duration = null)
{ {
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId)) if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId))
{ {
@ -87,19 +89,19 @@ public class BanCommandGroup : CommandGroup
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot)) if (!botResult.IsDefined(out var bot))
{ {
return Result.FromError(botResult); return ResultExtensions.FromError(botResult);
} }
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor)) if (!executorResult.IsDefined(out var executor))
{ {
return Result.FromError(executorResult); return ResultExtensions.FromError(executorResult);
} }
var guildResult = await _guildApi.GetGuildAsync(guildId, ct: CancellationToken); var guildResult = await _guildApi.GetGuildAsync(guildId, ct: CancellationToken);
if (!guildResult.IsDefined(out var guild)) if (!guildResult.IsDefined(out var guild))
{ {
return Result.FromError(guildResult); return ResultExtensions.FromError(guildResult);
} }
var data = await _guildData.GetData(guild.ID, CancellationToken); var data = await _guildData.GetData(guild.ID, CancellationToken);
@ -116,6 +118,7 @@ public class BanCommandGroup : CommandGroup
{ {
var failedEmbed = new EmbedBuilder() var failedEmbed = new EmbedBuilder()
.WithSmallTitle(Messages.InvalidTimeSpan, bot) .WithSmallTitle(Messages.InvalidTimeSpan, bot)
.WithDescription(Messages.TimeSpanExample)
.WithColour(ColorsList.Red) .WithColour(ColorsList.Red)
.Build(); .Build();
@ -126,7 +129,8 @@ public class BanCommandGroup : CommandGroup
} }
private async Task<Result> BanUserAsync( private async Task<Result> BanUserAsync(
IUser executor, IUser target, string reason, TimeSpan? duration, IGuild guild, GuildData data, Snowflake channelId, IUser executor, IUser target, string reason, TimeSpan? duration, IGuild guild, GuildData data,
Snowflake channelId,
IUser bot, CancellationToken ct = default) IUser bot, CancellationToken ct = default)
{ {
var existingBanResult = await _guildApi.GetGuildBanAsync(guild.ID, target.ID, ct); var existingBanResult = await _guildApi.GetGuildBanAsync(guild.ID, target.ID, ct);
@ -139,10 +143,10 @@ public class BanCommandGroup : CommandGroup
} }
var interactionResult var interactionResult
= await _utility.CheckInteractionsAsync(guild.ID, executor.ID, target.ID, "Ban", ct); = await _access.CheckInteractionsAsync(guild.ID, executor.ID, target.ID, "Ban", ct);
if (!interactionResult.IsSuccess) if (!interactionResult.IsSuccess)
{ {
return Result.FromError(interactionResult); return ResultExtensions.FromError(interactionResult);
} }
if (interactionResult.Entity is not null) if (interactionResult.Entity is not null)
@ -153,7 +157,8 @@ public class BanCommandGroup : CommandGroup
return await _feedback.SendContextualEmbedResultAsync(errorEmbed, ct: ct); return await _feedback.SendContextualEmbedResultAsync(errorEmbed, ct: ct);
} }
var builder = new StringBuilder().AppendBulletPointLine(string.Format(Messages.DescriptionActionReason, reason)); var builder =
new StringBuilder().AppendBulletPointLine(string.Format(Messages.DescriptionActionReason, reason));
if (duration is not null) if (duration is not null)
{ {
builder.AppendBulletPoint( builder.AppendBulletPoint(
@ -179,17 +184,19 @@ public class BanCommandGroup : CommandGroup
await _channelApi.CreateMessageWithEmbedResultAsync(dmChannel.ID, embedResult: dmEmbed, ct: ct); 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( var banResult = await _guildApi.CreateGuildBanAsync(
guild.ID, target.ID, reason: $"({executor.GetTag()}) {reason}".EncodeHeader(), guild.ID, target.ID, reason: $"({executor.GetTag()}) {reason}".EncodeHeader(),
ct: ct); ct: ct);
if (!banResult.IsSuccess) if (!banResult.IsSuccess)
{ {
return Result.FromError(banResult.Error); memberData.BannedUntil = null;
return ResultExtensions.FromError(banResult);
} }
var memberData = data.GetOrCreateMemberData(target.ID);
memberData.BannedUntil
= duration is not null ? DateTimeOffset.UtcNow.Add(duration.Value) : DateTimeOffset.MaxValue;
memberData.Roles.Clear(); memberData.Roles.Clear();
var embed = new EmbedBuilder().WithSmallTitle( var embed = new EmbedBuilder().WithSmallTitle(
@ -217,10 +224,10 @@ public class BanCommandGroup : CommandGroup
/// <seealso cref="ExecuteBanAsync" /> /// <seealso cref="ExecuteBanAsync" />
/// <seealso cref="MemberUpdateService.TickMemberDataAsync" /> /// <seealso cref="MemberUpdateService.TickMemberDataAsync" />
[Command("unban")] [Command("unban")]
[DiscordDefaultMemberPermissions(DiscordPermission.BanMembers)] [DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)]
[DiscordDefaultDMPermission(false)] [DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)] [RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.BanMembers)] [RequireDiscordPermission(DiscordPermission.ManageMessages)]
[RequireBotDiscordPermissions(DiscordPermission.BanMembers)] [RequireBotDiscordPermissions(DiscordPermission.BanMembers)]
[Description("Unban user")] [Description("Unban user")]
[UsedImplicitly] [UsedImplicitly]
@ -238,14 +245,14 @@ public class BanCommandGroup : CommandGroup
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot)) if (!botResult.IsDefined(out var bot))
{ {
return Result.FromError(botResult); return ResultExtensions.FromError(botResult);
} }
// Needed to get the tag and avatar // Needed to get the tag and avatar
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor)) if (!executorResult.IsDefined(out var executor))
{ {
return Result.FromError(executorResult); return ResultExtensions.FromError(executorResult);
} }
var data = await _guildData.GetData(guildId, CancellationToken); var data = await _guildData.GetData(guildId, CancellationToken);
@ -272,7 +279,7 @@ public class BanCommandGroup : CommandGroup
ct); ct);
if (!unbanResult.IsSuccess) if (!unbanResult.IsSuccess)
{ {
return Result.FromError(unbanResult.Error); return ResultExtensions.FromError(unbanResult);
} }
data.GetOrCreateMemberData(target.ID).BannedUntil = null; data.GetOrCreateMemberData(target.ID).BannedUntil = null;
@ -282,7 +289,8 @@ public class BanCommandGroup : CommandGroup
.WithColour(ColorsList.Green).Build(); .WithColour(ColorsList.Green).Build();
var title = string.Format(Messages.UserUnbanned, target.GetTag()); var title = string.Format(Messages.UserUnbanned, target.GetTag());
var description = new StringBuilder().AppendBulletPoint(string.Format(Messages.DescriptionActionReason, reason)); var description =
new StringBuilder().AppendBulletPoint(string.Format(Messages.DescriptionActionReason, reason));
_utility.LogAction( _utility.LogAction(
data.Settings, channelId, executor, title, description.ToString(), target, ColorsList.Green, ct: ct); data.Settings, channelId, executor, title, description.ToString(), target, ColorsList.Green, ct: ct);

View file

@ -1,9 +1,6 @@
using System.ComponentModel; using System.ComponentModel;
using System.Text; using System.Text;
using JetBrains.Annotations; using JetBrains.Annotations;
using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services;
using Remora.Commands.Attributes; using Remora.Commands.Attributes;
using Remora.Commands.Groups; using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
@ -16,14 +13,17 @@ using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting; using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core; using Remora.Rest.Core;
using Remora.Results; using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace Octobot.Commands; namespace TeamOctolings.Octobot.Commands;
/// <summary> /// <summary>
/// Handles the command to clear messages in a channel: /clear. /// Handles the command to clear messages in a channel: /clear.
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class ClearCommandGroup : CommandGroup public sealed class ClearCommandGroup : CommandGroup
{ {
private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestChannelAPI _channelApi;
private readonly ICommandContext _context; private readonly ICommandContext _context;
@ -64,6 +64,7 @@ public class ClearCommandGroup : CommandGroup
public async Task<Result> ExecuteClear( public async Task<Result> ExecuteClear(
[Description("Number of messages to remove (2-100)")] [MinValue(2)] [MaxValue(100)] [Description("Number of messages to remove (2-100)")] [MinValue(2)] [MaxValue(100)]
int amount, int amount,
[Description("Ignore messages except from the specified author")]
IUser? author = null) IUser? author = null)
{ {
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId)) if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId))
@ -75,20 +76,20 @@ public class ClearCommandGroup : CommandGroup
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot)) if (!botResult.IsDefined(out var bot))
{ {
return Result.FromError(botResult); return ResultExtensions.FromError(botResult);
} }
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor)) if (!executorResult.IsDefined(out var executor))
{ {
return Result.FromError(executorResult); return ResultExtensions.FromError(executorResult);
} }
var messagesResult = await _channelApi.GetChannelMessagesAsync( var messagesResult = await _channelApi.GetChannelMessagesAsync(
channelId, limit: amount + 1, ct: CancellationToken); channelId, limit: amount + 1, ct: CancellationToken);
if (!messagesResult.IsDefined(out var messages)) if (!messagesResult.IsDefined(out var messages))
{ {
return Result.FromError(messagesResult); return ResultExtensions.FromError(messagesResult);
} }
var data = await _guildData.GetData(guildId, CancellationToken); var data = await _guildData.GetData(guildId, CancellationToken);
@ -102,7 +103,9 @@ public class ClearCommandGroup : CommandGroup
CancellationToken ct = default) CancellationToken ct = default)
{ {
var idList = new List<Snowflake>(messages.Count); var idList = new List<Snowflake>(messages.Count);
var builder = new StringBuilder().AppendLine(Mention.Channel(channelId)).AppendLine();
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...') for (var i = messages.Count - 1; i >= 1; i--) // '>= 1' to skip last message ('Octobot is thinking...')
{ {
var message = messages[i]; var message = messages[i];
@ -112,8 +115,17 @@ public class ClearCommandGroup : CommandGroup
} }
idList.Add(message.ID); idList.Add(message.ID);
builder.AppendLine(string.Format(Messages.MessageFrom, Mention.User(message.Author)));
builder.Append(message.Content.InBlockCode()); 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) if (idList.Count == 0)
@ -127,21 +139,32 @@ public class ClearCommandGroup : CommandGroup
var title = author is not null var title = author is not null
? string.Format(Messages.MessagesClearedFiltered, idList.Count.ToString(), author.GetTag()) ? string.Format(Messages.MessagesClearedFiltered, idList.Count.ToString(), author.GetTag())
: string.Format(Messages.MessagesCleared, idList.Count.ToString()); : string.Format(Messages.MessagesCleared, idList.Count.ToString());
var description = builder.ToString();
var deleteResult = await _channelApi.BulkDeleteMessagesAsync( var deleteResult = await _channelApi.BulkDeleteMessagesAsync(
channelId, idList, executor.GetTag().EncodeHeader(), ct); channelId, idList, executor.GetTag().EncodeHeader(), ct);
if (!deleteResult.IsSuccess) if (!deleteResult.IsSuccess)
{ {
return Result.FromError(deleteResult.Error); return ResultExtensions.FromError(deleteResult);
} }
foreach (var log in logEntries)
{
_utility.LogAction( _utility.LogAction(
data.Settings, channelId, executor, title, description, bot, ColorsList.Red, false, ct); 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) var embed = new EmbedBuilder().WithSmallTitle(title, bot)
.WithColour(ColorsList.Green).Build(); .WithColour(ColorsList.Green).Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct); return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
} }
private sealed class ClearedMessageEntry
{
public StringBuilder Builder { get; } = new();
public int DeletedCount { get; set; }
}
} }

View file

@ -1,6 +1,5 @@
using JetBrains.Annotations; using JetBrains.Annotations;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Octobot.Extensions;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects; using Remora.Discord.API.Objects;
@ -11,17 +10,18 @@ using Remora.Discord.Commands.Services;
using Remora.Discord.Extensions.Embeds; using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting; using Remora.Discord.Extensions.Formatting;
using Remora.Results; using Remora.Results;
using TeamOctolings.Octobot.Extensions;
namespace Octobot.Commands.Events; namespace TeamOctolings.Octobot.Commands.Events;
/// <summary> /// <summary>
/// Handles error logging for slash command groups. /// Handles error logging for slash command groups.
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class ErrorLoggingPostExecutionEvent : IPostExecutionEvent public sealed class ErrorLoggingPostExecutionEvent : IPostExecutionEvent
{ {
private readonly ILogger<ErrorLoggingPostExecutionEvent> _logger;
private readonly IFeedbackService _feedback; private readonly IFeedbackService _feedback;
private readonly ILogger<ErrorLoggingPostExecutionEvent> _logger;
private readonly IDiscordRestUserAPI _userApi; private readonly IDiscordRestUserAPI _userApi;
public ErrorLoggingPostExecutionEvent(ILogger<ErrorLoggingPostExecutionEvent> logger, IFeedbackService feedback, public ErrorLoggingPostExecutionEvent(ILogger<ErrorLoggingPostExecutionEvent> logger, IFeedbackService feedback,
@ -53,13 +53,13 @@ public class ErrorLoggingPostExecutionEvent : IPostExecutionEvent
if (result.IsSuccess) if (result.IsSuccess)
{ {
return Result.FromSuccess(); return Result.Success;
} }
var botResult = await _userApi.GetCurrentUserAsync(ct); var botResult = await _userApi.GetCurrentUserAsync(ct);
if (!botResult.IsDefined(out var bot)) if (!botResult.IsDefined(out var bot))
{ {
return Result.FromError(botResult); return ResultExtensions.FromError(botResult);
} }
var embed = new EmbedBuilder().WithSmallTitle(Messages.CommandExecutionFailed, bot) var embed = new EmbedBuilder().WithSmallTitle(Messages.CommandExecutionFailed, bot)
@ -70,15 +70,19 @@ public class ErrorLoggingPostExecutionEvent : IPostExecutionEvent
var issuesButton = new ButtonComponent( var issuesButton = new ButtonComponent(
ButtonComponentStyle.Link, ButtonComponentStyle.Link,
Messages.ButtonReportIssue, BuildInfo.IsDirty
new PartialEmoji(Name: "⚠️"), ? Messages.ButtonDirty
URL: Octobot.IssuesUrl : Messages.ButtonReportIssue,
new PartialEmoji(Name: "\u26a0\ufe0f"), // 'WARNING SIGN' (U+26A0)
URL: BuildInfo.IssuesUrl,
IsDisabled: BuildInfo.IsDirty
); );
return await _feedback.SendContextualEmbedResultAsync(embed, return ResultExtensions.FromError(await _feedback.SendContextualEmbedResultAsync(embed,
new FeedbackMessageOptions(MessageComponents: new[] new FeedbackMessageOptions(MessageComponents: new[]
{ {
new ActionRowComponent(new[] { issuesButton }) new ActionRowComponent(new[] { issuesButton })
}), ct); }), ct)
);
} }
} }

View file

@ -1,17 +1,17 @@
using JetBrains.Annotations; using JetBrains.Annotations;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Octobot.Extensions;
using Remora.Discord.Commands.Contexts; using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Services; using Remora.Discord.Commands.Services;
using Remora.Results; using Remora.Results;
using TeamOctolings.Octobot.Extensions;
namespace Octobot.Commands.Events; namespace TeamOctolings.Octobot.Commands.Events;
/// <summary> /// <summary>
/// Handles error logging for slash commands that couldn't be successfully prepared. /// Handles error logging for slash commands that couldn't be successfully prepared.
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class LoggingPreparationErrorEvent : IPreparationErrorEvent public sealed class LoggingPreparationErrorEvent : IPreparationErrorEvent
{ {
private readonly ILogger<LoggingPreparationErrorEvent> _logger; private readonly ILogger<LoggingPreparationErrorEvent> _logger;
@ -33,6 +33,6 @@ public class LoggingPreparationErrorEvent : IPreparationErrorEvent
{ {
_logger.LogResult(preparationResult, "Error in slash command preparation."); _logger.LogResult(preparationResult, "Error in slash command preparation.");
return Task.FromResult(Result.FromSuccess()); return Task.FromResult(Result.Success);
} }
} }

View file

@ -2,10 +2,6 @@ using System.ComponentModel;
using System.Drawing; using System.Drawing;
using System.Text; using System.Text;
using JetBrains.Annotations; using JetBrains.Annotations;
using Octobot.Data;
using Octobot.Extensions;
using Octobot.Parsers;
using Octobot.Services;
using Remora.Commands.Attributes; using Remora.Commands.Attributes;
using Remora.Commands.Groups; using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
@ -17,14 +13,17 @@ using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting; using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core; using Remora.Rest.Core;
using Remora.Results; using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace Octobot.Commands; namespace TeamOctolings.Octobot.Commands;
/// <summary> /// <summary>
/// Handles tool commands: /userinfo, /guildinfo, /random, /timestamp, /8ball. /// Handles info commands: /userinfo, /guildinfo.
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class ToolsCommandGroup : CommandGroup public sealed class InfoCommandGroup : CommandGroup
{ {
private readonly ICommandContext _context; private readonly ICommandContext _context;
private readonly IFeedbackService _feedback; private readonly IFeedbackService _feedback;
@ -32,10 +31,10 @@ public class ToolsCommandGroup : CommandGroup
private readonly GuildDataService _guildData; private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi; private readonly IDiscordRestUserAPI _userApi;
public ToolsCommandGroup( public InfoCommandGroup(
ICommandContext context, IFeedbackService feedback, ICommandContext context, IFeedbackService feedback,
GuildDataService guildData, IDiscordRestGuildAPI guildApi, GuildDataService guildData, IDiscordRestGuildAPI guildApi,
IDiscordRestUserAPI userApi, IDiscordRestChannelAPI channelApi) IDiscordRestUserAPI userApi)
{ {
_context = context; _context = context;
_guildData = guildData; _guildData = guildData;
@ -81,13 +80,13 @@ public class ToolsCommandGroup : CommandGroup
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot)) if (!botResult.IsDefined(out var bot))
{ {
return Result.FromError(botResult); return ResultExtensions.FromError(botResult);
} }
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor)) if (!executorResult.IsDefined(out var executor))
{ {
return Result.FromError(executorResult); return ResultExtensions.FromError(executorResult);
} }
var data = await _guildData.GetData(guildId, CancellationToken); var data = await _guildData.GetData(guildId, CancellationToken);
@ -262,7 +261,7 @@ public class ToolsCommandGroup : CommandGroup
/// </returns> /// </returns>
[Command("guildinfo")] [Command("guildinfo")]
[DiscordDefaultDMPermission(false)] [DiscordDefaultDMPermission(false)]
[Description("Shows info current guild")] [Description("Shows info about current guild")]
[UsedImplicitly] [UsedImplicitly]
public async Task<Result> ExecuteGuildInfoAsync() public async Task<Result> ExecuteGuildInfoAsync()
{ {
@ -274,13 +273,13 @@ public class ToolsCommandGroup : CommandGroup
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot)) if (!botResult.IsDefined(out var bot))
{ {
return Result.FromError(botResult); return ResultExtensions.FromError(botResult);
} }
var guildResult = await _guildApi.GetGuildAsync(guildId, ct: CancellationToken); var guildResult = await _guildApi.GetGuildAsync(guildId, ct: CancellationToken);
if (!guildResult.IsDefined(out var guild)) if (!guildResult.IsDefined(out var guild))
{ {
return Result.FromError(guildResult); return ResultExtensions.FromError(guildResult);
} }
var data = await _guildData.GetData(guildId, CancellationToken); var data = await _guildData.GetData(guildId, CancellationToken);
@ -289,7 +288,7 @@ public class ToolsCommandGroup : CommandGroup
return await ShowGuildInfoAsync(bot, guild, CancellationToken); return await ShowGuildInfoAsync(bot, guild, CancellationToken);
} }
private Task<Result> ShowGuildInfoAsync(IUser bot, IGuild guild, CancellationToken ct) private Task<Result> ShowGuildInfoAsync(IUser bot, IGuild guild, CancellationToken ct = default)
{ {
var description = new StringBuilder().AppendLine($"## {guild.Name}"); var description = new StringBuilder().AppendLine($"## {guild.Name}");
@ -327,234 +326,4 @@ public class ToolsCommandGroup : CommandGroup
return _feedback.SendContextualEmbedResultAsync(embed, ct: ct); 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 Result.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 Result.FromError(botResult);
}
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return Result.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)
.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
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 Result.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

@ -1,9 +1,6 @@
using System.ComponentModel; using System.ComponentModel;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using JetBrains.Annotations; using JetBrains.Annotations;
using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services;
using Remora.Commands.Attributes; using Remora.Commands.Attributes;
using Remora.Commands.Groups; using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
@ -15,15 +12,19 @@ using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Extensions.Embeds; using Remora.Discord.Extensions.Embeds;
using Remora.Rest.Core; using Remora.Rest.Core;
using Remora.Results; using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace Octobot.Commands; namespace TeamOctolings.Octobot.Commands;
/// <summary> /// <summary>
/// Handles the command to kick members of a guild: /kick. /// Handles the command to kick members of a guild: /kick.
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class KickCommandGroup : CommandGroup public sealed class KickCommandGroup : CommandGroup
{ {
private readonly AccessControlService _access;
private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestChannelAPI _channelApi;
private readonly ICommandContext _context; private readonly ICommandContext _context;
private readonly IFeedbackService _feedback; private readonly IFeedbackService _feedback;
@ -32,16 +33,16 @@ public class KickCommandGroup : CommandGroup
private readonly IDiscordRestUserAPI _userApi; private readonly IDiscordRestUserAPI _userApi;
private readonly Utility _utility; private readonly Utility _utility;
public KickCommandGroup( public KickCommandGroup(AccessControlService access, IDiscordRestChannelAPI channelApi, ICommandContext context,
ICommandContext context, IDiscordRestChannelAPI channelApi, GuildDataService guildData, IFeedbackService feedback, IDiscordRestGuildAPI guildApi, GuildDataService guildData,
IFeedbackService feedback, IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi, IDiscordRestUserAPI userApi, Utility utility)
Utility utility)
{ {
_context = context; _access = access;
_channelApi = channelApi; _channelApi = channelApi;
_guildData = guildData; _context = context;
_feedback = feedback; _feedback = feedback;
_guildApi = guildApi; _guildApi = guildApi;
_guildData = guildData;
_userApi = userApi; _userApi = userApi;
_utility = utility; _utility = utility;
} }
@ -59,10 +60,10 @@ public class KickCommandGroup : CommandGroup
/// was kicked and vice-versa. /// was kicked and vice-versa.
/// </returns> /// </returns>
[Command("kick", "кик")] [Command("kick", "кик")]
[DiscordDefaultMemberPermissions(DiscordPermission.KickMembers)] [DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)]
[DiscordDefaultDMPermission(false)] [DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)] [RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.KickMembers)] [RequireDiscordPermission(DiscordPermission.ManageMessages)]
[RequireBotDiscordPermissions(DiscordPermission.KickMembers)] [RequireBotDiscordPermissions(DiscordPermission.KickMembers)]
[Description("Kick member")] [Description("Kick member")]
[UsedImplicitly] [UsedImplicitly]
@ -80,19 +81,19 @@ public class KickCommandGroup : CommandGroup
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot)) if (!botResult.IsDefined(out var bot))
{ {
return Result.FromError(botResult); return ResultExtensions.FromError(botResult);
} }
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor)) if (!executorResult.IsDefined(out var executor))
{ {
return Result.FromError(executorResult); return ResultExtensions.FromError(executorResult);
} }
var guildResult = await _guildApi.GetGuildAsync(guildId, ct: CancellationToken); var guildResult = await _guildApi.GetGuildAsync(guildId, ct: CancellationToken);
if (!guildResult.IsDefined(out var guild)) if (!guildResult.IsDefined(out var guild))
{ {
return Result.FromError(guildResult); return ResultExtensions.FromError(guildResult);
} }
var data = await _guildData.GetData(guildId, CancellationToken); var data = await _guildData.GetData(guildId, CancellationToken);
@ -115,10 +116,10 @@ public class KickCommandGroup : CommandGroup
CancellationToken ct = default) CancellationToken ct = default)
{ {
var interactionResult var interactionResult
= await _utility.CheckInteractionsAsync(guild.ID, executor.ID, target.ID, "Kick", ct); = await _access.CheckInteractionsAsync(guild.ID, executor.ID, target.ID, "Kick", ct);
if (!interactionResult.IsSuccess) if (!interactionResult.IsSuccess)
{ {
return Result.FromError(interactionResult); return ResultExtensions.FromError(interactionResult);
} }
if (interactionResult.Entity is not null) if (interactionResult.Entity is not null)
@ -134,7 +135,8 @@ public class KickCommandGroup : CommandGroup
{ {
var dmEmbed = new EmbedBuilder().WithGuildTitle(guild) var dmEmbed = new EmbedBuilder().WithGuildTitle(guild)
.WithTitle(Messages.YouWereKicked) .WithTitle(Messages.YouWereKicked)
.WithDescription(MarkdownExtensions.BulletPoint(string.Format(Messages.DescriptionActionReason, reason))) .WithDescription(
MarkdownExtensions.BulletPoint(string.Format(Messages.DescriptionActionReason, reason)))
.WithActionFooter(executor) .WithActionFooter(executor)
.WithCurrentTimestamp() .WithCurrentTimestamp()
.WithColour(ColorsList.Red) .WithColour(ColorsList.Red)
@ -143,17 +145,19 @@ public class KickCommandGroup : CommandGroup
await _channelApi.CreateMessageWithEmbedResultAsync(dmChannel.ID, embedResult: dmEmbed, ct: ct); await _channelApi.CreateMessageWithEmbedResultAsync(dmChannel.ID, embedResult: dmEmbed, ct: ct);
} }
var memberData = data.GetOrCreateMemberData(target.ID);
memberData.Kicked = true;
var kickResult = await _guildApi.RemoveGuildMemberAsync( var kickResult = await _guildApi.RemoveGuildMemberAsync(
guild.ID, target.ID, $"({executor.GetTag()}) {reason}".EncodeHeader(), guild.ID, target.ID, $"({executor.GetTag()}) {reason}".EncodeHeader(),
ct); ct);
if (!kickResult.IsSuccess) if (!kickResult.IsSuccess)
{ {
return Result.FromError(kickResult.Error); memberData.Kicked = false;
return ResultExtensions.FromError(kickResult);
} }
var memberData = data.GetOrCreateMemberData(target.ID);
memberData.Roles.Clear(); memberData.Roles.Clear();
memberData.Kicked = true;
var title = string.Format(Messages.UserKicked, target.GetTag()); var title = string.Format(Messages.UserKicked, target.GetTag());
var description = MarkdownExtensions.BulletPoint(string.Format(Messages.DescriptionActionReason, reason)); var description = MarkdownExtensions.BulletPoint(string.Format(Messages.DescriptionActionReason, reason));

View file

@ -2,11 +2,6 @@ using System.ComponentModel;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Text; using System.Text;
using JetBrains.Annotations; using JetBrains.Annotations;
using Octobot.Data;
using Octobot.Extensions;
using Octobot.Parsers;
using Octobot.Services;
using Octobot.Services.Update;
using Remora.Commands.Attributes; using Remora.Commands.Attributes;
using Remora.Commands.Groups; using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
@ -19,15 +14,21 @@ using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting; using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core; using Remora.Rest.Core;
using Remora.Results; 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 Octobot.Commands; namespace TeamOctolings.Octobot.Commands;
/// <summary> /// <summary>
/// Handles commands related to mute management: /mute and /unmute. /// Handles commands related to mute management: /mute and /unmute.
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class MuteCommandGroup : CommandGroup public sealed class MuteCommandGroup : CommandGroup
{ {
private readonly AccessControlService _access;
private readonly ICommandContext _context; private readonly ICommandContext _context;
private readonly IFeedbackService _feedback; private readonly IFeedbackService _feedback;
private readonly IDiscordRestGuildAPI _guildApi; private readonly IDiscordRestGuildAPI _guildApi;
@ -35,14 +36,14 @@ public class MuteCommandGroup : CommandGroup
private readonly IDiscordRestUserAPI _userApi; private readonly IDiscordRestUserAPI _userApi;
private readonly Utility _utility; private readonly Utility _utility;
public MuteCommandGroup( public MuteCommandGroup(AccessControlService access, ICommandContext context, IFeedbackService feedback,
ICommandContext context, GuildDataService guildData, IFeedbackService feedback, IDiscordRestGuildAPI guildApi, GuildDataService guildData, IDiscordRestUserAPI userApi, Utility utility)
IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi, Utility utility)
{ {
_access = access;
_context = context; _context = context;
_guildData = guildData;
_feedback = feedback; _feedback = feedback;
_guildApi = guildApi; _guildApi = guildApi;
_guildData = guildData;
_userApi = userApi; _userApi = userApi;
_utility = utility; _utility = utility;
} }
@ -62,10 +63,10 @@ public class MuteCommandGroup : CommandGroup
/// </returns> /// </returns>
/// <seealso cref="ExecuteUnmute" /> /// <seealso cref="ExecuteUnmute" />
[Command("mute", "мут")] [Command("mute", "мут")]
[DiscordDefaultMemberPermissions(DiscordPermission.ModerateMembers)] [DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)]
[DiscordDefaultDMPermission(false)] [DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)] [RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ModerateMembers)] [RequireDiscordPermission(DiscordPermission.ManageMessages)]
[RequireBotDiscordPermissions(DiscordPermission.ModerateMembers)] [RequireBotDiscordPermissions(DiscordPermission.ModerateMembers)]
[Description("Mute member")] [Description("Mute member")]
[UsedImplicitly] [UsedImplicitly]
@ -73,7 +74,7 @@ public class MuteCommandGroup : CommandGroup
[Description("Member to mute")] IUser target, [Description("Member to mute")] IUser target,
[Description("Mute reason")] [MaxLength(256)] [Description("Mute reason")] [MaxLength(256)]
string reason, string reason,
[Description("Mute duration")] [Option("duration")] [Description("Mute duration (e.g. 1h30m)")] [Option("duration")]
string stringDuration) string stringDuration)
{ {
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId)) if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId))
@ -85,13 +86,13 @@ public class MuteCommandGroup : CommandGroup
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot)) if (!botResult.IsDefined(out var bot))
{ {
return Result.FromError(botResult); return ResultExtensions.FromError(botResult);
} }
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor)) if (!executorResult.IsDefined(out var executor))
{ {
return Result.FromError(executorResult); return ResultExtensions.FromError(executorResult);
} }
var data = await _guildData.GetData(guildId, CancellationToken); var data = await _guildData.GetData(guildId, CancellationToken);
@ -111,13 +112,15 @@ public class MuteCommandGroup : CommandGroup
{ {
var failedEmbed = new EmbedBuilder() var failedEmbed = new EmbedBuilder()
.WithSmallTitle(Messages.InvalidTimeSpan, bot) .WithSmallTitle(Messages.InvalidTimeSpan, bot)
.WithDescription(Messages.TimeSpanExample)
.WithColour(ColorsList.Red) .WithColour(ColorsList.Red)
.Build(); .Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: CancellationToken); return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: CancellationToken);
} }
return await MuteUserAsync(executor, target, reason, duration, guildId, data, channelId, bot, CancellationToken); return await MuteUserAsync(executor, target, reason, duration, guildId, data, channelId, bot,
CancellationToken);
} }
private async Task<Result> MuteUserAsync( private async Task<Result> MuteUserAsync(
@ -125,11 +128,11 @@ public class MuteCommandGroup : CommandGroup
Snowflake channelId, IUser bot, CancellationToken ct = default) Snowflake channelId, IUser bot, CancellationToken ct = default)
{ {
var interactionResult var interactionResult
= await _utility.CheckInteractionsAsync( = await _access.CheckInteractionsAsync(
guildId, executor.ID, target.ID, "Mute", ct); guildId, executor.ID, target.ID, "Mute", ct);
if (!interactionResult.IsSuccess) if (!interactionResult.IsSuccess)
{ {
return Result.FromError(interactionResult); return ResultExtensions.FromError(interactionResult);
} }
if (interactionResult.Entity is not null) if (interactionResult.Entity is not null)
@ -142,14 +145,16 @@ public class MuteCommandGroup : CommandGroup
var until = DateTimeOffset.UtcNow.Add(duration); // >:) var until = DateTimeOffset.UtcNow.Add(duration); // >:)
var muteMethodResult = await SelectMuteMethodAsync(executor, target, reason, duration, guildId, data, bot, until, ct); var muteMethodResult =
await SelectMuteMethodAsync(executor, target, reason, duration, guildId, data, bot, until, ct);
if (!muteMethodResult.IsSuccess) if (!muteMethodResult.IsSuccess)
{ {
return muteMethodResult; return ResultExtensions.FromError(muteMethodResult);
} }
var title = string.Format(Messages.UserMuted, target.GetTag()); var title = string.Format(Messages.UserMuted, target.GetTag());
var description = new StringBuilder().AppendBulletPointLine(string.Format(Messages.DescriptionActionReason, reason)) var description = new StringBuilder()
.AppendBulletPointLine(string.Format(Messages.DescriptionActionReason, reason))
.AppendBulletPoint(string.Format( .AppendBulletPoint(string.Format(
Messages.DescriptionActionExpiresAt, Markdown.Timestamp(until))).ToString(); Messages.DescriptionActionExpiresAt, Markdown.Timestamp(until))).ToString();
@ -165,7 +170,7 @@ public class MuteCommandGroup : CommandGroup
private async Task<Result> SelectMuteMethodAsync( private async Task<Result> SelectMuteMethodAsync(
IUser executor, IUser target, string reason, TimeSpan duration, Snowflake guildId, GuildData data, IUser executor, IUser target, string reason, TimeSpan duration, Snowflake guildId, GuildData data,
IUser bot, DateTimeOffset until, CancellationToken ct) IUser bot, DateTimeOffset until, CancellationToken ct = default)
{ {
var muteRole = GuildSettings.MuteRole.Get(data.Settings); var muteRole = GuildSettings.MuteRole.Get(data.Settings);
@ -181,7 +186,7 @@ public class MuteCommandGroup : CommandGroup
private async Task<Result> RoleMuteUserAsync( private async Task<Result> RoleMuteUserAsync(
IUser executor, IUser target, string reason, Snowflake guildId, GuildData data, IUser executor, IUser target, string reason, Snowflake guildId, GuildData data,
DateTimeOffset until, Snowflake muteRole, CancellationToken ct) DateTimeOffset until, Snowflake muteRole, CancellationToken ct = default)
{ {
var assignRoles = new List<Snowflake> { muteRole }; var assignRoles = new List<Snowflake> { muteRole };
var memberData = data.GetOrCreateMemberData(target.ID); var memberData = data.GetOrCreateMemberData(target.ID);
@ -203,7 +208,7 @@ public class MuteCommandGroup : CommandGroup
private async Task<Result> TimeoutUserAsync( private async Task<Result> TimeoutUserAsync(
IUser executor, IUser target, string reason, TimeSpan duration, Snowflake guildId, IUser executor, IUser target, string reason, TimeSpan duration, Snowflake guildId,
IUser bot, DateTimeOffset until, CancellationToken ct) IUser bot, DateTimeOffset until, CancellationToken ct = default)
{ {
if (duration.TotalDays >= 28) if (duration.TotalDays >= 28)
{ {
@ -235,10 +240,10 @@ public class MuteCommandGroup : CommandGroup
/// <seealso cref="ExecuteMute" /> /// <seealso cref="ExecuteMute" />
/// <seealso cref="MemberUpdateService.TickMemberDataAsync" /> /// <seealso cref="MemberUpdateService.TickMemberDataAsync" />
[Command("unmute", "размут")] [Command("unmute", "размут")]
[DiscordDefaultMemberPermissions(DiscordPermission.ModerateMembers)] [DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)]
[DiscordDefaultDMPermission(false)] [DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)] [RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ModerateMembers)] [RequireDiscordPermission(DiscordPermission.ManageMessages)]
[RequireBotDiscordPermissions(DiscordPermission.ModerateMembers)] [RequireBotDiscordPermissions(DiscordPermission.ModerateMembers)]
[Description("Unmute member")] [Description("Unmute member")]
[UsedImplicitly] [UsedImplicitly]
@ -256,14 +261,14 @@ public class MuteCommandGroup : CommandGroup
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot)) if (!botResult.IsDefined(out var bot))
{ {
return Result.FromError(botResult); return ResultExtensions.FromError(botResult);
} }
// Needed to get the tag and avatar // Needed to get the tag and avatar
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor)) if (!executorResult.IsDefined(out var executor))
{ {
return Result.FromError(executorResult); return ResultExtensions.FromError(executorResult);
} }
var data = await _guildData.GetData(guildId, CancellationToken); var data = await _guildData.GetData(guildId, CancellationToken);
@ -286,11 +291,11 @@ public class MuteCommandGroup : CommandGroup
IUser bot, CancellationToken ct = default) IUser bot, CancellationToken ct = default)
{ {
var interactionResult var interactionResult
= await _utility.CheckInteractionsAsync( = await _access.CheckInteractionsAsync(
guildId, executor.ID, target.ID, "Unmute", ct); guildId, executor.ID, target.ID, "Unmute", ct);
if (!interactionResult.IsSuccess) if (!interactionResult.IsSuccess)
{ {
return Result.FromError(interactionResult); return ResultExtensions.FromError(interactionResult);
} }
if (interactionResult.Entity is not null) if (interactionResult.Entity is not null)
@ -323,14 +328,14 @@ public class MuteCommandGroup : CommandGroup
await RemoveMuteRoleAsync(executor, target, reason, guildId, memberData, CancellationToken); await RemoveMuteRoleAsync(executor, target, reason, guildId, memberData, CancellationToken);
if (!removeMuteRoleAsync.IsSuccess) if (!removeMuteRoleAsync.IsSuccess)
{ {
return Result.FromError(removeMuteRoleAsync.Error); return ResultExtensions.FromError(removeMuteRoleAsync);
} }
var removeTimeoutResult = var removeTimeoutResult =
await RemoveTimeoutAsync(executor, target, reason, guildId, communicationDisabledUntil, CancellationToken); await RemoveTimeoutAsync(executor, target, reason, guildId, communicationDisabledUntil, CancellationToken);
if (!removeTimeoutResult.IsSuccess) if (!removeTimeoutResult.IsSuccess)
{ {
return Result.FromError(removeTimeoutResult.Error); return ResultExtensions.FromError(removeTimeoutResult);
} }
var title = string.Format(Messages.UserUnmuted, target.GetTag()); var title = string.Format(Messages.UserUnmuted, target.GetTag());
@ -347,11 +352,12 @@ public class MuteCommandGroup : CommandGroup
} }
private async Task<Result> RemoveMuteRoleAsync( private async Task<Result> RemoveMuteRoleAsync(
IUser executor, IUser target, string reason, Snowflake guildId, MemberData memberData, CancellationToken ct = default) IUser executor, IUser target, string reason, Snowflake guildId, MemberData memberData,
CancellationToken ct = default)
{ {
if (memberData.MutedUntil is null) if (memberData.MutedUntil is null)
{ {
return Result.FromSuccess(); return Result.Success;
} }
var unmuteResult = await _guildApi.ModifyGuildMemberAsync( var unmuteResult = await _guildApi.ModifyGuildMemberAsync(
@ -371,7 +377,7 @@ public class MuteCommandGroup : CommandGroup
{ {
if (communicationDisabledUntil is null) if (communicationDisabledUntil is null)
{ {
return Result.FromSuccess(); return Result.Success;
} }
var unmuteResult = await _guildApi.ModifyGuildMemberAsync( var unmuteResult = await _guildApi.ModifyGuildMemberAsync(

View file

@ -1,8 +1,5 @@
using System.ComponentModel; using System.ComponentModel;
using JetBrains.Annotations; using JetBrains.Annotations;
using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services;
using Remora.Commands.Attributes; using Remora.Commands.Attributes;
using Remora.Commands.Groups; using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
@ -15,14 +12,17 @@ using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway; using Remora.Discord.Gateway;
using Remora.Rest.Core; using Remora.Rest.Core;
using Remora.Results; using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace Octobot.Commands; namespace TeamOctolings.Octobot.Commands;
/// <summary> /// <summary>
/// Handles the command to get the time taken for the gateway to respond to the last heartbeat: /ping /// Handles the command to get the time taken for the gateway to respond to the last heartbeat: /ping
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class PingCommandGroup : CommandGroup public sealed class PingCommandGroup : CommandGroup
{ {
private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestChannelAPI _channelApi;
private readonly DiscordGatewayClient _client; private readonly DiscordGatewayClient _client;
@ -64,7 +64,7 @@ public class PingCommandGroup : CommandGroup
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot)) if (!botResult.IsDefined(out var bot))
{ {
return Result.FromError(botResult); return ResultExtensions.FromError(botResult);
} }
var cfg = await _guildData.GetSettings(guildId, CancellationToken); var cfg = await _guildData.GetSettings(guildId, CancellationToken);
@ -84,14 +84,14 @@ public class PingCommandGroup : CommandGroup
channelId, limit: 1, ct: ct); channelId, limit: 1, ct: ct);
if (!lastMessageResult.IsDefined(out var lastMessage)) if (!lastMessageResult.IsDefined(out var lastMessage))
{ {
return Result.FromError(lastMessageResult); return ResultExtensions.FromError(lastMessageResult);
} }
latency = DateTimeOffset.UtcNow.Subtract(lastMessage.Single().Timestamp).TotalMilliseconds; latency = DateTimeOffset.UtcNow.Subtract(lastMessage.Single().Timestamp).TotalMilliseconds;
} }
var embed = new EmbedBuilder().WithSmallTitle(bot.GetTag(), bot) var embed = new EmbedBuilder().WithSmallTitle(bot.GetTag(), bot)
.WithTitle($"Sound{Random.Shared.Next(1, 4)}".Localized()) .WithTitle($"Generic{Random.Shared.Next(1, 4)}".Localized())
.WithDescription($"{latency:F0}{Messages.Milliseconds}") .WithDescription($"{latency:F0}{Messages.Milliseconds}")
.WithColour(latency < 250 ? ColorsList.Green : latency < 500 ? ColorsList.Yellow : ColorsList.Red) .WithColour(latency < 250 ? ColorsList.Green : latency < 500 ? ColorsList.Yellow : ColorsList.Red)
.WithCurrentTimestamp() .WithCurrentTimestamp()

View file

@ -2,9 +2,6 @@ using System.ComponentModel;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Text; using System.Text;
using JetBrains.Annotations; using JetBrains.Annotations;
using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services;
using Remora.Commands.Attributes; using Remora.Commands.Attributes;
using Remora.Commands.Groups; using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
@ -17,21 +14,24 @@ using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting; using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core; using Remora.Rest.Core;
using Remora.Results; using Remora.Results;
using Octobot.Parsers; using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Parsers;
using TeamOctolings.Octobot.Services;
namespace Octobot.Commands; namespace TeamOctolings.Octobot.Commands;
/// <summary> /// <summary>
/// Handles commands to manage reminders: /remind, /listremind, /delremind /// Handles commands to manage reminders: /remind, /listremind, /delremind
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class RemindCommandGroup : CommandGroup public sealed class RemindCommandGroup : CommandGroup
{ {
private readonly IInteractionCommandContext _context; private readonly IInteractionCommandContext _context;
private readonly IFeedbackService _feedback; private readonly IFeedbackService _feedback;
private readonly GuildDataService _guildData; private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi;
private readonly IDiscordRestInteractionAPI _interactionApi; private readonly IDiscordRestInteractionAPI _interactionApi;
private readonly IDiscordRestUserAPI _userApi;
public RemindCommandGroup( public RemindCommandGroup(
IInteractionCommandContext context, GuildDataService guildData, IFeedbackService feedback, IInteractionCommandContext context, GuildDataService guildData, IFeedbackService feedback,
@ -63,13 +63,13 @@ public class RemindCommandGroup : CommandGroup
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot)) if (!botResult.IsDefined(out var bot))
{ {
return Result.FromError(botResult); return ResultExtensions.FromError(botResult);
} }
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor)) if (!executorResult.IsDefined(out var executor))
{ {
return Result.FromError(executorResult); return ResultExtensions.FromError(executorResult);
} }
var data = await _guildData.GetData(guildId, CancellationToken); var data = await _guildData.GetData(guildId, CancellationToken);
@ -78,7 +78,7 @@ public class RemindCommandGroup : CommandGroup
return await ListRemindersAsync(data.GetOrCreateMemberData(executorId), guildId, executor, bot, CancellationToken); return await ListRemindersAsync(data.GetOrCreateMemberData(executorId), guildId, executor, bot, CancellationToken);
} }
private Task<Result> ListRemindersAsync(MemberData data, Snowflake guildId, IUser executor, IUser bot, CancellationToken ct) private Task<Result> ListRemindersAsync(MemberData data, Snowflake guildId, IUser executor, IUser bot, CancellationToken ct = default)
{ {
if (data.Reminders.Count == 0) if (data.Reminders.Count == 0)
{ {
@ -94,7 +94,7 @@ public class RemindCommandGroup : CommandGroup
{ {
var reminder = data.Reminders[i]; var reminder = data.Reminders[i];
builder.AppendBulletPointLine(string.Format(Messages.ReminderPosition, Markdown.InlineCode((i + 1).ToString()))) builder.AppendBulletPointLine(string.Format(Messages.ReminderPosition, Markdown.InlineCode((i + 1).ToString())))
.AppendSubBulletPointLine(string.Format(Messages.ReminderText, Markdown.InlineCode(reminder.Text))) .AppendSubBulletPointLine(string.Format(Messages.ReminderText, reminder.Text))
.AppendSubBulletPointLine(string.Format(Messages.ReminderTime, Markdown.Timestamp(reminder.At))) .AppendSubBulletPointLine(string.Format(Messages.ReminderTime, Markdown.Timestamp(reminder.At)))
.AppendSubBulletPointLine(string.Format(Messages.DescriptionActionJumpToMessage, $"https://discord.com/channels/{guildId.Value}/{reminder.ChannelId}/{reminder.MessageId}")); .AppendSubBulletPointLine(string.Format(Messages.DescriptionActionJumpToMessage, $"https://discord.com/channels/{guildId.Value}/{reminder.ChannelId}/{reminder.MessageId}"));
} }
@ -120,7 +120,7 @@ public class RemindCommandGroup : CommandGroup
[RequireContext(ChannelContext.Guild)] [RequireContext(ChannelContext.Guild)]
[UsedImplicitly] [UsedImplicitly]
public async Task<Result> ExecuteReminderAsync( public async Task<Result> ExecuteReminderAsync(
[Description("After what period of time mention the reminder")] [Description("After what period of time mention the reminder (e.g. 1h30m)")]
[Option("in")] [Option("in")]
string timeSpanString, string timeSpanString,
[Description("Reminder text")] [MaxLength(512)] [Description("Reminder text")] [MaxLength(512)]
@ -134,13 +134,13 @@ public class RemindCommandGroup : CommandGroup
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot)) if (!botResult.IsDefined(out var bot))
{ {
return Result.FromError(botResult); return ResultExtensions.FromError(botResult);
} }
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor)) if (!executorResult.IsDefined(out var executor))
{ {
return Result.FromError(executorResult); return ResultExtensions.FromError(executorResult);
} }
var data = await _guildData.GetData(guildId, CancellationToken); var data = await _guildData.GetData(guildId, CancellationToken);
@ -151,6 +151,7 @@ public class RemindCommandGroup : CommandGroup
{ {
var failedEmbed = new EmbedBuilder() var failedEmbed = new EmbedBuilder()
.WithSmallTitle(Messages.InvalidTimeSpan, bot) .WithSmallTitle(Messages.InvalidTimeSpan, bot)
.WithDescription(Messages.TimeSpanExample)
.WithColour(ColorsList.Red) .WithColour(ColorsList.Red)
.Build(); .Build();
@ -181,7 +182,7 @@ public class RemindCommandGroup : CommandGroup
}); });
var builder = new StringBuilder() var builder = new StringBuilder()
.AppendBulletPointLine(string.Format(Messages.ReminderText, Markdown.InlineCode(text))) .AppendLine(MarkdownExtensions.Quote(text))
.AppendBulletPoint(string.Format(Messages.ReminderTime, Markdown.Timestamp(remindAt))); .AppendBulletPoint(string.Format(Messages.ReminderTime, Markdown.Timestamp(remindAt)));
var embed = new EmbedBuilder().WithSmallTitle( var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.ReminderCreated, executor.GetTag()), executor) string.Format(Messages.ReminderCreated, executor.GetTag()), executor)
@ -225,13 +226,13 @@ public class RemindCommandGroup : CommandGroup
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot)) if (!botResult.IsDefined(out var bot))
{ {
return Result.FromError(botResult); return ResultExtensions.FromError(botResult);
} }
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor)) if (!executorResult.IsDefined(out var executor))
{ {
return Result.FromError(executorResult); return ResultExtensions.FromError(executorResult);
} }
var data = await _guildData.GetData(guildId, CancellationToken); var data = await _guildData.GetData(guildId, CancellationToken);
@ -264,6 +265,7 @@ public class RemindCommandGroup : CommandGroup
{ {
var failedEmbed = new EmbedBuilder() var failedEmbed = new EmbedBuilder()
.WithSmallTitle(Messages.InvalidTimeSpan, bot) .WithSmallTitle(Messages.InvalidTimeSpan, bot)
.WithDescription(Messages.TimeSpanExample)
.WithColour(ColorsList.Red) .WithColour(ColorsList.Red)
.Build(); .Build();
@ -277,7 +279,7 @@ public class RemindCommandGroup : CommandGroup
data.Reminders.RemoveAt(index); data.Reminders.RemoveAt(index);
var builder = new StringBuilder() var builder = new StringBuilder()
.AppendBulletPointLine(string.Format(Messages.ReminderText, Markdown.InlineCode(oldReminder.Text))) .AppendLine(MarkdownExtensions.Quote(oldReminder.Text))
.AppendBulletPoint(string.Format(Messages.ReminderTime, Markdown.Timestamp(remindAt))); .AppendBulletPoint(string.Format(Messages.ReminderTime, Markdown.Timestamp(remindAt)));
var embed = new EmbedBuilder().WithSmallTitle( var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.ReminderEdited, executor.GetTag()), executor) string.Format(Messages.ReminderEdited, executor.GetTag()), executor)
@ -307,7 +309,7 @@ public class RemindCommandGroup : CommandGroup
data.Reminders.RemoveAt(index); data.Reminders.RemoveAt(index);
var builder = new StringBuilder() var builder = new StringBuilder()
.AppendBulletPointLine(string.Format(Messages.ReminderText, Markdown.InlineCode(value))) .AppendLine(MarkdownExtensions.Quote(value))
.AppendBulletPoint(string.Format(Messages.ReminderTime, Markdown.Timestamp(oldReminder.At))); .AppendBulletPoint(string.Format(Messages.ReminderTime, Markdown.Timestamp(oldReminder.At)));
var embed = new EmbedBuilder().WithSmallTitle( var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.ReminderEdited, executor.GetTag()), executor) string.Format(Messages.ReminderEdited, executor.GetTag()), executor)
@ -341,7 +343,7 @@ public class RemindCommandGroup : CommandGroup
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot)) if (!botResult.IsDefined(out var bot))
{ {
return Result.FromError(botResult); return ResultExtensions.FromError(botResult);
} }
var data = await _guildData.GetData(guildId, CancellationToken); var data = await _guildData.GetData(guildId, CancellationToken);
@ -351,7 +353,7 @@ public class RemindCommandGroup : CommandGroup
} }
private Task<Result> DeleteReminderAsync(MemberData data, int index, IUser bot, private Task<Result> DeleteReminderAsync(MemberData data, int index, IUser bot,
CancellationToken ct) CancellationToken ct = default)
{ {
if (index >= data.Reminders.Count) if (index >= data.Reminders.Count)
{ {
@ -365,7 +367,7 @@ public class RemindCommandGroup : CommandGroup
var reminder = data.Reminders[index]; var reminder = data.Reminders[index];
var description = new StringBuilder() var description = new StringBuilder()
.AppendBulletPointLine(string.Format(Messages.ReminderText, Markdown.InlineCode(reminder.Text))) .AppendLine(MarkdownExtensions.Quote(reminder.Text))
.AppendBulletPointLine(string.Format(Messages.ReminderTime, Markdown.Timestamp(reminder.At))); .AppendBulletPointLine(string.Format(Messages.ReminderTime, Markdown.Timestamp(reminder.At)));
data.Reminders.RemoveAt(index); data.Reminders.RemoveAt(index);

View file

@ -3,10 +3,6 @@ using System.ComponentModel.DataAnnotations;
using System.Text; using System.Text;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using JetBrains.Annotations; using JetBrains.Annotations;
using Octobot.Data;
using Octobot.Data.Options;
using Octobot.Extensions;
using Octobot.Services;
using Remora.Commands.Attributes; using Remora.Commands.Attributes;
using Remora.Commands.Groups; using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
@ -19,26 +15,31 @@ using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting; using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core; using Remora.Rest.Core;
using Remora.Results; using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Data.Options;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace Octobot.Commands; namespace TeamOctolings.Octobot.Commands;
/// <summary> /// <summary>
/// Handles the commands to list and modify per-guild settings: /settings and /settings list. /// Handles the commands to list and modify per-guild settings: /settings and /settings list.
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class SettingsCommandGroup : CommandGroup public sealed class SettingsCommandGroup : CommandGroup
{ {
/// <summary> /// <summary>
/// Represents all options as an array of objects implementing <see cref="IOption" />. /// Represents all options as an array of objects implementing <see cref="IGuildOption" />.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// WARNING: If you update this array in any way, you must also update <see cref="AllOptionsEnum" /> and make sure /// WARNING: If you update this array in any way, you must also update <see cref="AllOptionsEnum" /> and make sure
/// that the orders match. /// that the orders match.
/// </remarks> /// </remarks>
private static readonly IOption[] AllOptions = private static readonly IGuildOption[] AllOptions =
[ [
GuildSettings.Language, GuildSettings.Language,
GuildSettings.WelcomeMessage, GuildSettings.WelcomeMessage,
GuildSettings.LeaveMessage,
GuildSettings.ReceiveStartupMessages, GuildSettings.ReceiveStartupMessages,
GuildSettings.RemoveRolesOnMute, GuildSettings.RemoveRolesOnMute,
GuildSettings.ReturnRolesOnRejoin, GuildSettings.ReturnRolesOnRejoin,
@ -46,9 +47,11 @@ public class SettingsCommandGroup : CommandGroup
GuildSettings.RenameHoistedUsers, GuildSettings.RenameHoistedUsers,
GuildSettings.PublicFeedbackChannel, GuildSettings.PublicFeedbackChannel,
GuildSettings.PrivateFeedbackChannel, GuildSettings.PrivateFeedbackChannel,
GuildSettings.WelcomeMessagesChannel,
GuildSettings.EventNotificationChannel, GuildSettings.EventNotificationChannel,
GuildSettings.DefaultRole, GuildSettings.DefaultRole,
GuildSettings.MuteRole, GuildSettings.MuteRole,
GuildSettings.ModeratorRole,
GuildSettings.EventNotificationRole, GuildSettings.EventNotificationRole,
GuildSettings.EventEarlyNotificationOffset GuildSettings.EventEarlyNotificationOffset
]; ];
@ -96,7 +99,7 @@ public class SettingsCommandGroup : CommandGroup
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot)) if (!botResult.IsDefined(out var bot))
{ {
return Result.FromError(botResult); return ResultExtensions.FromError(botResult);
} }
var cfg = await _guildData.GetSettings(guildId, CancellationToken); var cfg = await _guildData.GetSettings(guildId, CancellationToken);
@ -179,13 +182,13 @@ public class SettingsCommandGroup : CommandGroup
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot)) if (!botResult.IsDefined(out var bot))
{ {
return Result.FromError(botResult); return ResultExtensions.FromError(botResult);
} }
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor)) if (!executorResult.IsDefined(out var executor))
{ {
return Result.FromError(executorResult); return ResultExtensions.FromError(executorResult);
} }
var data = await _guildData.GetData(guildId, CancellationToken); var data = await _guildData.GetData(guildId, CancellationToken);
@ -196,9 +199,30 @@ public class SettingsCommandGroup : CommandGroup
} }
private async Task<Result> EditSettingAsync( private async Task<Result> EditSettingAsync(
IOption option, string value, GuildData data, Snowflake channelId, IUser executor, IUser bot, IGuildOption option, string value, GuildData data, Snowflake channelId, IUser executor, IUser bot,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var equalsResult = option.ValueEquals(data.Settings, value);
if (!equalsResult.IsSuccess)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.SettingNotChanged, bot)
.WithDescription(equalsResult.Error.Message)
.WithColour(ColorsList.Red)
.Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
if (equalsResult.Entity)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.SettingNotChanged, bot)
.WithDescription(Messages.SettingValueEquals)
.WithColour(ColorsList.Red)
.Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var setResult = option.Set(data.Settings, value); var setResult = option.Set(data.Settings, value);
if (!setResult.IsSuccess) if (!setResult.IsSuccess)
{ {
@ -239,7 +263,7 @@ public class SettingsCommandGroup : CommandGroup
[DiscordDefaultDMPermission(false)] [DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)] [RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ManageGuild)] [RequireDiscordPermission(DiscordPermission.ManageGuild)]
[Description("Reset settings for this server")] [Description("Reset settings for this guild")]
[UsedImplicitly] [UsedImplicitly]
public async Task<Result> ExecuteResetSettingsAsync( public async Task<Result> ExecuteResetSettingsAsync(
[Description("Setting to reset")] AllOptionsEnum? setting = null) [Description("Setting to reset")] AllOptionsEnum? setting = null)
@ -252,7 +276,7 @@ public class SettingsCommandGroup : CommandGroup
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot)) if (!botResult.IsDefined(out var bot))
{ {
return Result.FromError(botResult); return ResultExtensions.FromError(botResult);
} }
var cfg = await _guildData.GetSettings(guildId, CancellationToken); var cfg = await _guildData.GetSettings(guildId, CancellationToken);
@ -267,12 +291,12 @@ public class SettingsCommandGroup : CommandGroup
} }
private async Task<Result> ResetSingleSettingAsync(JsonNode cfg, IUser bot, private async Task<Result> ResetSingleSettingAsync(JsonNode cfg, IUser bot,
IOption option, CancellationToken ct = default) IGuildOption option, CancellationToken ct = default)
{ {
var resetResult = option.Reset(cfg); var resetResult = option.Reset(cfg);
if (!resetResult.IsSuccess) if (!resetResult.IsSuccess)
{ {
return Result.FromError(resetResult.Error); return ResultExtensions.FromError(resetResult);
} }
var embed = new EmbedBuilder().WithSmallTitle( var embed = new EmbedBuilder().WithSmallTitle(

View file

@ -0,0 +1,272 @@
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.Contexts;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
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: /random, /timestamp, /8ball.
/// </summary>
[UsedImplicitly]
public sealed class ToolsCommandGroup : CommandGroup
{
private static readonly TimestampStyle[] AllStyles =
[
TimestampStyle.ShortDate,
TimestampStyle.LongDate,
TimestampStyle.ShortTime,
TimestampStyle.LongTime,
TimestampStyle.ShortDateTime,
TimestampStyle.LongDateTime,
TimestampStyle.RelativeTime
];
private static readonly string[] AnswerTypes =
[
"Positive", "Questionable", "Neutral", "Negative"
];
private readonly ICommandContext _context;
private readonly IFeedbackService _feedback;
private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi;
public ToolsCommandGroup(
ICommandContext context, IFeedbackService feedback,
GuildDataService guildData, IDiscordRestUserAPI userApi)
{
_context = context;
_guildData = guildData;
_feedback = feedback;
_userApi = userApi;
}
/// <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 = default)
{
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);
}
/// <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 = default)
{
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 Task<Result> AnswerEightBallAsync(IUser bot, CancellationToken ct = default)
{
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

@ -1,7 +1,7 @@
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using Remora.Rest.Core; using Remora.Rest.Core;
namespace Octobot.Data; namespace TeamOctolings.Octobot.Data;
/// <summary> /// <summary>
/// Stores information about a guild. This information is not accessible via the Discord API. /// Stores information about a guild. This information is not accessible via the Discord API.

View file

@ -1,8 +1,8 @@
using Octobot.Data.Options;
using Octobot.Responders;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using TeamOctolings.Octobot.Data.Options;
using TeamOctolings.Octobot.Responders;
namespace Octobot.Data; namespace TeamOctolings.Octobot.Data;
/// <summary> /// <summary>
/// Contains all per-guild settings that can be set by a member /// Contains all per-guild settings that can be set by a member
@ -13,16 +13,28 @@ public static class GuildSettings
public static readonly LanguageOption Language = new("Language", "en"); public static readonly LanguageOption Language = new("Language", "en");
/// <summary> /// <summary>
/// Controls what message should be sent in <see cref="PublicFeedbackChannel" /> when a new member joins the server. /// Controls what message should be sent in <see cref="PublicFeedbackChannel" /> when a new member joins the guild.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <list type="bullet"> /// <list type="bullet">
/// <item>No message will be sent if set to "off", "disable" or "disabled".</item> /// <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> /// <item><see cref="Messages.DefaultWelcomeMessage" /> will be sent if set to "default" or "reset".</item>
/// </list> /// </list>
/// </remarks> /// </remarks>
/// <seealso cref="GuildMemberJoinedResponder" /> /// <seealso cref="GuildMemberJoinedResponder" />
public static readonly Option<string> WelcomeMessage = new("WelcomeMessage", "default"); 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> /// <summary>
/// Controls whether or not the <see cref="Messages.Ready" /> message should be sent /// Controls whether or not the <see cref="Messages.Ready" /> message should be sent
@ -56,9 +68,15 @@ public static class GuildSettings
/// </summary> /// </summary>
public static readonly SnowflakeOption PrivateFeedbackChannel = new("PrivateFeedbackChannel"); 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 EventNotificationChannel = new("EventNotificationChannel");
public static readonly SnowflakeOption DefaultRole = new("DefaultRole"); public static readonly SnowflakeOption DefaultRole = new("DefaultRole");
public static readonly SnowflakeOption MuteRole = new("MuteRole"); public static readonly SnowflakeOption MuteRole = new("MuteRole");
public static readonly SnowflakeOption ModeratorRole = new("ModeratorRole");
public static readonly SnowflakeOption EventNotificationRole = new("EventNotificationRole"); public static readonly SnowflakeOption EventNotificationRole = new("EventNotificationRole");
/// <summary> /// <summary>

View file

@ -1,14 +1,13 @@
namespace Octobot.Data; namespace TeamOctolings.Octobot.Data;
/// <summary> /// <summary>
/// Stores information about a member /// Stores information about a member
/// </summary> /// </summary>
public sealed class MemberData public sealed class MemberData
{ {
public MemberData(ulong id, DateTimeOffset? bannedUntil = null, List<Reminder>? reminders = null) public MemberData(ulong id, List<Reminder>? reminders = null)
{ {
Id = id; Id = id;
BannedUntil = bannedUntil;
if (reminders is not null) if (reminders is not null)
{ {
Reminders = reminders; Reminders = reminders;

View file

@ -1,7 +1,7 @@
using JetBrains.Annotations; using JetBrains.Annotations;
using Octobot.Commands; using TeamOctolings.Octobot.Commands;
namespace Octobot.Data.Options; namespace TeamOctolings.Octobot.Data.Options;
/// <summary> /// <summary>
/// Represents all options as enums. /// Represents all options as enums.
@ -14,6 +14,7 @@ public enum AllOptionsEnum
{ {
[UsedImplicitly] Language, [UsedImplicitly] Language,
[UsedImplicitly] WelcomeMessage, [UsedImplicitly] WelcomeMessage,
[UsedImplicitly] LeaveMessage,
[UsedImplicitly] ReceiveStartupMessages, [UsedImplicitly] ReceiveStartupMessages,
[UsedImplicitly] RemoveRolesOnMute, [UsedImplicitly] RemoveRolesOnMute,
[UsedImplicitly] ReturnRolesOnRejoin, [UsedImplicitly] ReturnRolesOnRejoin,
@ -21,9 +22,11 @@ public enum AllOptionsEnum
[UsedImplicitly] RenameHoistedUsers, [UsedImplicitly] RenameHoistedUsers,
[UsedImplicitly] PublicFeedbackChannel, [UsedImplicitly] PublicFeedbackChannel,
[UsedImplicitly] PrivateFeedbackChannel, [UsedImplicitly] PrivateFeedbackChannel,
[UsedImplicitly] WelcomeMessagesChannel,
[UsedImplicitly] EventNotificationChannel, [UsedImplicitly] EventNotificationChannel,
[UsedImplicitly] DefaultRole, [UsedImplicitly] DefaultRole,
[UsedImplicitly] MuteRole, [UsedImplicitly] MuteRole,
[UsedImplicitly] ModeratorRole,
[UsedImplicitly] EventNotificationRole, [UsedImplicitly] EventNotificationRole,
[UsedImplicitly] EventEarlyNotificationOffset [UsedImplicitly] EventEarlyNotificationOffset
} }

View file

@ -1,9 +1,9 @@
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using Remora.Results; using Remora.Results;
namespace Octobot.Data.Options; namespace TeamOctolings.Octobot.Data.Options;
public sealed class BoolOption : Option<bool> public sealed class BoolOption : GuildOption<bool>
{ {
public BoolOption(string name, bool defaultValue) : base(name, defaultValue) { } public BoolOption(string name, bool defaultValue) : base(name, defaultValue) { }
@ -12,6 +12,16 @@ public sealed class BoolOption : Option<bool>
return Get(settings) ? Messages.Yes : Messages.No; return Get(settings) ? Messages.Yes : Messages.No;
} }
public override Result<bool> ValueEquals(JsonNode settings, string value)
{
if (!TryParseBool(value, out var boolean))
{
return new ArgumentInvalidError(nameof(value), Messages.InvalidSettingValue);
}
return Value(settings).Equals(boolean.ToString());
}
public override Result Set(JsonNode settings, string from) public override Result Set(JsonNode settings, string from)
{ {
if (!TryParseBool(from, out var value)) if (!TryParseBool(from, out var value))
@ -20,7 +30,7 @@ public sealed class BoolOption : Option<bool>
} }
settings[Name] = value; settings[Name] = value;
return Result.FromSuccess(); return Result.Success;
} }
private static bool TryParseBool(string from, out bool value) private static bool TryParseBool(string from, out bool value)

View file

@ -2,18 +2,18 @@ using System.Text.Json.Nodes;
using Remora.Discord.Extensions.Formatting; using Remora.Discord.Extensions.Formatting;
using Remora.Results; using Remora.Results;
namespace Octobot.Data.Options; namespace TeamOctolings.Octobot.Data.Options;
/// <summary> /// <summary>
/// Represents an per-guild option. /// Represents a per-guild option.
/// </summary> /// </summary>
/// <typeparam name="T">The type of the option.</typeparam> /// <typeparam name="T">The type of the option.</typeparam>
public class Option<T> : IOption public class GuildOption<T> : IGuildOption
where T : notnull where T : notnull
{ {
protected readonly T DefaultValue; protected readonly T DefaultValue;
public Option(string name, T defaultValue) public GuildOption(string name, T defaultValue)
{ {
Name = name; Name = name;
DefaultValue = defaultValue; DefaultValue = defaultValue;
@ -21,9 +21,19 @@ public class Option<T> : IOption
public string Name { get; } public string Name { get; }
protected virtual string Value(JsonNode settings)
{
return Get(settings).ToString() ?? throw new InvalidOperationException();
}
public virtual string Display(JsonNode settings) public virtual string Display(JsonNode settings)
{ {
return Markdown.InlineCode(Get(settings).ToString() ?? throw new InvalidOperationException()); return Markdown.InlineCode(Value(settings));
}
public virtual Result<bool> ValueEquals(JsonNode settings, string value)
{
return Value(settings).Equals(value);
} }
/// <summary> /// <summary>
@ -35,7 +45,13 @@ public class Option<T> : IOption
public virtual Result Set(JsonNode settings, string from) public virtual Result Set(JsonNode settings, string from)
{ {
settings[Name] = from; settings[Name] = from;
return Result.FromSuccess(); return Result.Success;
}
public Result Reset(JsonNode settings)
{
settings[Name] = null;
return Result.Success;
} }
/// <summary> /// <summary>
@ -48,10 +64,4 @@ public class Option<T> : IOption
var property = settings[Name]; var property = settings[Name];
return property != null ? property.GetValue<T>() : DefaultValue; return property != null ? property.GetValue<T>() : DefaultValue;
} }
public Result Reset(JsonNode settings)
{
settings[Name] = null;
return Result.FromSuccess();
}
} }

View file

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

View file

@ -1,25 +1,23 @@
using System.Globalization; using System.Globalization;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using Remora.Discord.Extensions.Formatting;
using Remora.Results; using Remora.Results;
namespace Octobot.Data.Options; namespace TeamOctolings.Octobot.Data.Options;
/// <inheritdoc /> /// <inheritdoc />
public sealed class LanguageOption : Option<CultureInfo> public sealed class LanguageOption : GuildOption<CultureInfo>
{ {
private static readonly Dictionary<string, CultureInfo> CultureInfoCache = new() private static readonly Dictionary<string, CultureInfo> CultureInfoCache = new()
{ {
{ "en", new CultureInfo("en-US") }, { "en", new CultureInfo("en-US") },
{ "ru", new CultureInfo("ru-RU") }, { "ru", new CultureInfo("ru-RU") }
{ "mctaylors-ru", new CultureInfo("tt-RU") }
}; };
public LanguageOption(string name, string defaultValue) : base(name, CultureInfoCache[defaultValue]) { } public LanguageOption(string name, string defaultValue) : base(name, CultureInfoCache[defaultValue]) { }
public override string Display(JsonNode settings) protected override string Value(JsonNode settings)
{ {
return Markdown.InlineCode(settings[Name]?.GetValue<string>() ?? "en"); return settings[Name]?.GetValue<string>() ?? "en";
} }
/// <inheritdoc /> /// <inheritdoc />

View file

@ -1,13 +1,13 @@
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Octobot.Extensions;
using Remora.Discord.Extensions.Formatting; using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core; using Remora.Rest.Core;
using Remora.Results; using Remora.Results;
using TeamOctolings.Octobot.Extensions;
namespace Octobot.Data.Options; namespace TeamOctolings.Octobot.Data.Options;
public sealed partial class SnowflakeOption : Option<Snowflake> public sealed partial class SnowflakeOption : GuildOption<Snowflake>
{ {
public SnowflakeOption(string name) : base(name, 0UL.ToSnowflake()) { } public SnowflakeOption(string name) : base(name, 0UL.ToSnowflake()) { }
@ -32,7 +32,7 @@ public sealed partial class SnowflakeOption : Option<Snowflake>
} }
settings[Name] = parsed; settings[Name] = parsed;
return Result.FromSuccess(); return Result.Success;
} }
[GeneratedRegex("[^0-9]")] [GeneratedRegex("[^0-9]")]

View file

@ -1,13 +1,23 @@
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using Octobot.Parsers;
using Remora.Results; using Remora.Results;
using TeamOctolings.Octobot.Parsers;
namespace Octobot.Data.Options; namespace TeamOctolings.Octobot.Data.Options;
public sealed class TimeSpanOption : Option<TimeSpan> public sealed class TimeSpanOption : GuildOption<TimeSpan>
{ {
public TimeSpanOption(string name, TimeSpan defaultValue) : base(name, defaultValue) { } public TimeSpanOption(string name, TimeSpan defaultValue) : base(name, defaultValue) { }
public override Result<bool> ValueEquals(JsonNode settings, string value)
{
if (!TimeSpanParser.TryParse(value).IsDefined(out var span))
{
return new ArgumentInvalidError(nameof(value), Messages.InvalidSettingValue);
}
return Value(settings).Equals(span.ToString());
}
public override TimeSpan Get(JsonNode settings) public override TimeSpan Get(JsonNode settings)
{ {
var property = settings[Name]; var property = settings[Name];
@ -22,6 +32,6 @@ public sealed class TimeSpanOption : Option<TimeSpan>
} }
settings[Name] = span.ToString(); settings[Name] = span.ToString();
return Result.FromSuccess(); return Result.Success;
} }
} }

View file

@ -1,4 +1,4 @@
namespace Octobot.Data; namespace TeamOctolings.Octobot.Data;
public struct Reminder public struct Reminder
{ {

View file

@ -1,7 +1,7 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
namespace Octobot.Data; namespace TeamOctolings.Octobot.Data;
/// <summary> /// <summary>
/// Stores information about scheduled events. This information is not provided by the Discord API. /// Stores information about scheduled events. This information is not provided by the Discord API.

View file

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

View file

@ -1,6 +1,6 @@
using Remora.Results; using Remora.Results;
namespace Octobot.Extensions; namespace TeamOctolings.Octobot.Extensions;
public static class CollectionExtensions public static class CollectionExtensions
{ {
@ -32,7 +32,7 @@ public static class CollectionExtensions
{ {
return list.Count switch return list.Count switch
{ {
0 => Result.FromSuccess(), 0 => Result.Success,
1 => list[0], 1 => list[0],
_ => new AggregateError(list.Cast<IResult>().ToArray()) _ => new AggregateError(list.Cast<IResult>().ToArray())
}; };

View file

@ -2,7 +2,7 @@
using Remora.Discord.Commands.Extensions; using Remora.Discord.Commands.Extensions;
using Remora.Rest.Core; using Remora.Rest.Core;
namespace Octobot.Extensions; namespace TeamOctolings.Octobot.Extensions;
public static class CommandContextExtensions public static class CommandContextExtensions
{ {

View file

@ -1,7 +1,7 @@
using System.Text; using System.Text;
using DiffPlex.DiffBuilder.Model; using DiffPlex.DiffBuilder.Model;
namespace Octobot.Extensions; namespace TeamOctolings.Octobot.Extensions;
public static class DiffPaneModelExtensions public static class DiffPaneModelExtensions
{ {

View file

@ -4,7 +4,7 @@ using Remora.Discord.API.Objects;
using Remora.Discord.Extensions.Embeds; using Remora.Discord.Extensions.Embeds;
using Remora.Rest.Core; using Remora.Rest.Core;
namespace Octobot.Extensions; namespace TeamOctolings.Octobot.Extensions;
public static class EmbedBuilderExtensions public static class EmbedBuilderExtensions
{ {

View file

@ -3,7 +3,7 @@ using Remora.Discord.Commands.Feedback.Messages;
using Remora.Discord.Commands.Feedback.Services; using Remora.Discord.Commands.Feedback.Services;
using Remora.Results; using Remora.Results;
namespace Octobot.Extensions; namespace TeamOctolings.Octobot.Extensions;
public static class FeedbackServiceExtensions public static class FeedbackServiceExtensions
{ {
@ -13,7 +13,7 @@ public static class FeedbackServiceExtensions
{ {
if (!embedResult.IsDefined(out var embed)) if (!embedResult.IsDefined(out var embed))
{ {
return Result.FromError(embedResult); return ResultExtensions.FromError(embedResult);
} }
return (Result)await feedback.SendContextualEmbedAsync(embed, options, ct); return (Result)await feedback.SendContextualEmbedAsync(embed, options, ct);

View file

@ -2,7 +2,7 @@
using Remora.Rest.Core; using Remora.Rest.Core;
using Remora.Results; using Remora.Results;
namespace Octobot.Extensions; namespace TeamOctolings.Octobot.Extensions;
public static class GuildScheduledEventExtensions public static class GuildScheduledEventExtensions
{ {
@ -22,7 +22,7 @@ public static class GuildScheduledEventExtensions
} }
return scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out endTime) return scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out endTime)
? Result.FromSuccess() ? Result.Success
: new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime)); : new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime));
} }
} }

View file

@ -1,8 +1,7 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Remora.Discord.Commands.Extensions;
using Remora.Results; using Remora.Results;
namespace Octobot.Extensions; namespace TeamOctolings.Octobot.Extensions;
public static class LoggerExtensions public static class LoggerExtensions
{ {
@ -19,14 +18,14 @@ public static class LoggerExtensions
/// <param name="message">The message to use if this result has failed.</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 = "") public static void LogResult(this ILogger logger, IResult result, string? message = "")
{ {
if (result.IsSuccess || result.Error.IsUserOrEnvironmentError()) if (result.IsSuccess)
{ {
return; return;
} }
if (result.Error is ExceptionError exe) if (result.Error is ExceptionError exe)
{ {
if (exe.Exception is TaskCanceledException) if (exe.Exception is OperationCanceledException)
{ {
return; return;
} }

View file

@ -1,4 +1,4 @@
namespace Octobot.Extensions; namespace TeamOctolings.Octobot.Extensions;
public static class MarkdownExtensions public static class MarkdownExtensions
{ {
@ -13,4 +13,16 @@ public static class MarkdownExtensions
{ {
return $"- {text}"; return $"- {text}";
} }
/// <summary>
/// Formats a string to use Markdown Quote formatting.
/// </summary>
/// <param name="text">The input text to format.</param>
/// <returns>
/// A markdown-formatted quote string.
/// </returns>
public static string Quote(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 || result.Error is ExceptionError { Exception: OperationCanceledException })
{
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

@ -1,6 +1,6 @@
using Remora.Rest.Core; using Remora.Rest.Core;
namespace Octobot.Extensions; namespace TeamOctolings.Octobot.Extensions;
public static class SnowflakeExtensions public static class SnowflakeExtensions
{ {

View file

@ -1,6 +1,6 @@
using System.Text; using System.Text;
namespace Octobot.Extensions; namespace TeamOctolings.Octobot.Extensions;
public static class StringBuilderExtensions public static class StringBuilderExtensions
{ {

View file

@ -1,7 +1,7 @@
using System.Net; using System.Net;
using Remora.Discord.Extensions.Formatting; using Remora.Discord.Extensions.Formatting;
namespace Octobot.Extensions; namespace TeamOctolings.Octobot.Extensions;
public static class StringExtensions public static class StringExtensions
{ {

View file

@ -1,7 +1,7 @@
using Remora.Discord.API; using Remora.Discord.API;
using Remora.Rest.Core; using Remora.Rest.Core;
namespace Octobot.Extensions; namespace TeamOctolings.Octobot.Extensions;
public static class UInt64Extensions public static class UInt64Extensions
{ {

View file

@ -1,6 +1,6 @@
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
namespace Octobot.Extensions; namespace TeamOctolings.Octobot.Extensions;
public static class UserExtensions public static class UserExtensions
{ {

View file

@ -7,7 +7,10 @@
// </auto-generated> // </auto-generated>
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
namespace Octobot { namespace TeamOctolings.Octobot {
using System;
[System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
[System.Diagnostics.DebuggerNonUserCodeAttribute()] [System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.Runtime.CompilerServices.CompilerGeneratedAttribute()] [System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
@ -25,7 +28,7 @@ namespace Octobot {
internal static System.Resources.ResourceManager ResourceManager { internal static System.Resources.ResourceManager ResourceManager {
get { get {
if (object.Equals(null, resourceMan)) { if (object.Equals(null, resourceMan)) {
System.Resources.ResourceManager temp = new System.Resources.ResourceManager("Octobot.locale.Messages", typeof(Messages).Assembly); System.Resources.ResourceManager temp = new System.Resources.ResourceManager("TeamOctolings.Octobot.Messages", typeof(Messages).Assembly);
resourceMan = temp; resourceMan = temp;
} }
return resourceMan; return resourceMan;
@ -66,21 +69,21 @@ namespace Octobot {
} }
} }
internal static string Sound1 { internal static string Generic1 {
get { get {
return ResourceManager.GetString("Sound1", resourceCulture); return ResourceManager.GetString("Generic1", resourceCulture);
} }
} }
internal static string Sound2 { internal static string Generic2 {
get { get {
return ResourceManager.GetString("Sound2", resourceCulture); return ResourceManager.GetString("Generic2", resourceCulture);
} }
} }
internal static string Sound3 { internal static string Generic3 {
get { get {
return ResourceManager.GetString("Sound3", resourceCulture); return ResourceManager.GetString("Generic3", resourceCulture);
} }
} }
@ -120,9 +123,9 @@ namespace Octobot {
} }
} }
internal static string SettingsLang { internal static string SettingsLanguage {
get { get {
return ResourceManager.GetString("SettingsLang", resourceCulture); return ResourceManager.GetString("SettingsLanguage", resourceCulture);
} }
} }
@ -204,18 +207,6 @@ namespace Octobot {
} }
} }
internal static string InvalidRole {
get {
return ResourceManager.GetString("InvalidRole", resourceCulture);
}
}
internal static string InvalidChannel {
get {
return ResourceManager.GetString("InvalidChannel", resourceCulture);
}
}
internal static string DurationRequiredForTimeOuts { internal static string DurationRequiredForTimeOuts {
get { get {
return ResourceManager.GetString("DurationRequiredForTimeOuts", resourceCulture); return ResourceManager.GetString("DurationRequiredForTimeOuts", resourceCulture);
@ -282,6 +273,12 @@ namespace Octobot {
} }
} }
internal static string MissingUser {
get {
return ResourceManager.GetString("MissingUser", resourceCulture);
}
}
internal static string UserCannotBanMembers { internal static string UserCannotBanMembers {
get { get {
return ResourceManager.GetString("UserCannotBanMembers", resourceCulture); return ResourceManager.GetString("UserCannotBanMembers", resourceCulture);
@ -300,9 +297,15 @@ namespace Octobot {
} }
} }
internal static string UserCannotModerateMembers { internal static string UserCannotMuteMembers {
get { get {
return ResourceManager.GetString("UserCannotModerateMembers", resourceCulture); return ResourceManager.GetString("UserCannotMuteMembers", resourceCulture);
}
}
internal static string UserCannotUnmuteMembers {
get {
return ResourceManager.GetString("UserCannotUnmuteMembers", resourceCulture);
} }
} }
@ -702,6 +705,12 @@ namespace Octobot {
} }
} }
internal static string SettingsRenameHoistedUsers {
get {
return ResourceManager.GetString("SettingsRenameHoistedUsers", resourceCulture);
}
}
internal static string Page { internal static string Page {
get { get {
return ResourceManager.GetString("Page", resourceCulture); return ResourceManager.GetString("Page", resourceCulture);
@ -798,21 +807,15 @@ namespace Octobot {
} }
} }
internal static string InformationAbout {
get {
return ResourceManager.GetString("InformationAbout", resourceCulture);
}
}
internal static string UserInfoDisplayName { internal static string UserInfoDisplayName {
get { get {
return ResourceManager.GetString("UserInfoDisplayName", resourceCulture); return ResourceManager.GetString("UserInfoDisplayName", resourceCulture);
} }
} }
internal static string UserInfoDiscordUserSince { internal static string InformationAbout {
get { get {
return ResourceManager.GetString("UserInfoDiscordUserSince", resourceCulture); return ResourceManager.GetString("InformationAbout", resourceCulture);
} }
} }
@ -822,6 +825,12 @@ namespace Octobot {
} }
} }
internal static string UserInfoDiscordUserSince {
get {
return ResourceManager.GetString("UserInfoDiscordUserSince", resourceCulture);
}
}
internal static string UserInfoBanned { internal static string UserInfoBanned {
get { get {
return ResourceManager.GetString("UserInfoBanned", resourceCulture); return ResourceManager.GetString("UserInfoBanned", resourceCulture);
@ -882,173 +891,146 @@ namespace Octobot {
} }
} }
internal static string RandomTitle internal static string RandomTitle {
{
get { get {
return ResourceManager.GetString("RandomTitle", resourceCulture); return ResourceManager.GetString("RandomTitle", resourceCulture);
} }
} }
internal static string RandomMinMaxSame internal static string RandomMinMaxSame {
{
get { get {
return ResourceManager.GetString("RandomMinMaxSame", resourceCulture); return ResourceManager.GetString("RandomMinMaxSame", resourceCulture);
} }
} }
internal static string RandomMax internal static string RandomMin {
{
get {
return ResourceManager.GetString("RandomMax", resourceCulture);
}
}
internal static string RandomMin
{
get { get {
return ResourceManager.GetString("RandomMin", resourceCulture); return ResourceManager.GetString("RandomMin", resourceCulture);
} }
} }
internal static string Default internal static string RandomMax {
{ get {
return ResourceManager.GetString("RandomMax", resourceCulture);
}
}
internal static string Default {
get { get {
return ResourceManager.GetString("Default", resourceCulture); return ResourceManager.GetString("Default", resourceCulture);
} }
} }
internal static string TimestampTitle internal static string TimestampTitle {
{ get {
get
{
return ResourceManager.GetString("TimestampTitle", resourceCulture); return ResourceManager.GetString("TimestampTitle", resourceCulture);
} }
} }
internal static string TimestampOffset internal static string TimestampOffset {
{ get {
get
{
return ResourceManager.GetString("TimestampOffset", resourceCulture); return ResourceManager.GetString("TimestampOffset", resourceCulture);
} }
} }
internal static string GuildInfoDescription internal static string GuildInfoDescription {
{ get {
get
{
return ResourceManager.GetString("GuildInfoDescription", resourceCulture); return ResourceManager.GetString("GuildInfoDescription", resourceCulture);
} }
} }
internal static string GuildInfoCreatedAt internal static string GuildInfoCreatedAt {
{ get {
get
{
return ResourceManager.GetString("GuildInfoCreatedAt", resourceCulture); return ResourceManager.GetString("GuildInfoCreatedAt", resourceCulture);
} }
} }
internal static string GuildInfoOwner internal static string GuildInfoOwner {
{ get {
get
{
return ResourceManager.GetString("GuildInfoOwner", resourceCulture); return ResourceManager.GetString("GuildInfoOwner", resourceCulture);
} }
} }
internal static string GuildInfoServerBoost internal static string GuildInfoServerBoost {
{ get {
get
{
return ResourceManager.GetString("GuildInfoServerBoost", resourceCulture); return ResourceManager.GetString("GuildInfoServerBoost", resourceCulture);
} }
} }
internal static string GuildInfoBoostTier internal static string GuildInfoBoostTier {
{ get {
get
{
return ResourceManager.GetString("GuildInfoBoostTier", resourceCulture); return ResourceManager.GetString("GuildInfoBoostTier", resourceCulture);
} }
} }
internal static string GuildInfoBoostCount internal static string GuildInfoBoostCount {
{ get {
get
{
return ResourceManager.GetString("GuildInfoBoostCount", resourceCulture); return ResourceManager.GetString("GuildInfoBoostCount", resourceCulture);
} }
} }
internal static string NoMessagesToClear internal static string NoMessagesToClear {
{ get {
get
{
return ResourceManager.GetString("NoMessagesToClear", resourceCulture); return ResourceManager.GetString("NoMessagesToClear", resourceCulture);
} }
} }
internal static string MessagesClearedFiltered internal static string MessagesClearedFiltered {
{ get {
get
{
return ResourceManager.GetString("MessagesClearedFiltered", resourceCulture); return ResourceManager.GetString("MessagesClearedFiltered", resourceCulture);
} }
} }
internal static string DataLoadFailedTitle internal static string DataLoadFailedTitle {
{ get {
get
{
return ResourceManager.GetString("DataLoadFailedTitle", resourceCulture); return ResourceManager.GetString("DataLoadFailedTitle", resourceCulture);
} }
} }
internal static string DataLoadFailedDescription internal static string DataLoadFailedDescription {
{ get {
get
{
return ResourceManager.GetString("DataLoadFailedDescription", resourceCulture); return ResourceManager.GetString("DataLoadFailedDescription", resourceCulture);
} }
} }
internal static string CommandExecutionFailed internal static string CommandExecutionFailed {
{ get {
get
{
return ResourceManager.GetString("CommandExecutionFailed", resourceCulture); return ResourceManager.GetString("CommandExecutionFailed", resourceCulture);
} }
} }
internal static string ContactDevelopers internal static string ContactDevelopers {
{ get {
get
{
return ResourceManager.GetString("ContactDevelopers", resourceCulture); return ResourceManager.GetString("ContactDevelopers", resourceCulture);
} }
} }
internal static string ButtonReportIssue internal static string ButtonReportIssue {
{ get {
get
{
return ResourceManager.GetString("ButtonReportIssue", resourceCulture); return ResourceManager.GetString("ButtonReportIssue", resourceCulture);
} }
} }
internal static string InvalidTimeSpan internal static string DefaultLeaveMessage {
{ get {
get return ResourceManager.GetString("DefaultLeaveMessage", resourceCulture);
{ }
}
internal static string SettingsLeaveMessage {
get {
return ResourceManager.GetString("SettingsLeaveMessage", resourceCulture);
}
}
internal static string InvalidTimeSpan {
get {
return ResourceManager.GetString("InvalidTimeSpan", resourceCulture); return ResourceManager.GetString("InvalidTimeSpan", resourceCulture);
} }
} }
internal static string UserInfoKicked internal static string UserInfoKicked {
{ get {
get
{
return ResourceManager.GetString("UserInfoKicked", resourceCulture); return ResourceManager.GetString("UserInfoKicked", resourceCulture);
} }
} }
@ -1178,5 +1160,47 @@ namespace Octobot {
return ResourceManager.GetString("EightBallNegative5", resourceCulture); return ResourceManager.GetString("EightBallNegative5", resourceCulture);
} }
} }
internal static string TimeSpanExample {
get {
return ResourceManager.GetString("TimeSpanExample", resourceCulture);
}
}
internal static string Version {
get {
return ResourceManager.GetString("Version", resourceCulture);
}
}
internal static string SettingsWelcomeMessagesChannel {
get {
return ResourceManager.GetString("SettingsWelcomeMessagesChannel", resourceCulture);
}
}
internal static string ButtonDirty {
get {
return ResourceManager.GetString("ButtonDirty", resourceCulture);
}
}
internal static string ButtonOpenWiki {
get {
return ResourceManager.GetString("ButtonOpenWiki", resourceCulture);
}
}
internal static string SettingsModeratorRole {
get {
return ResourceManager.GetString("SettingsModeratorRole", resourceCulture);
}
}
internal static string SettingValueEquals {
get {
return ResourceManager.GetString("SettingValueEquals", resourceCulture);
}
}
} }
} }

View file

@ -117,13 +117,13 @@
<data name="DefaultWelcomeMessage" xml:space="preserve"> <data name="DefaultWelcomeMessage" xml:space="preserve">
<value>{0}, welcome to {1}</value> <value>{0}, welcome to {1}</value>
</data> </data>
<data name="Sound1" xml:space="preserve"> <data name="Generic1" xml:space="preserve">
<value>Veemo!</value> <value>Veemo!</value>
</data> </data>
<data name="Sound2" xml:space="preserve"> <data name="Generic2" xml:space="preserve">
<value>Woomy!</value> <value>Woomy!</value>
</data> </data>
<data name="Sound3" xml:space="preserve"> <data name="Generic3" xml:space="preserve">
<value>Ngyes!</value> <value>Ngyes!</value>
</data> </data>
<data name="YouWereBanned" xml:space="preserve"> <data name="YouWereBanned" xml:space="preserve">
@ -231,8 +231,11 @@
<data name="UserCannotKickMembers" xml:space="preserve"> <data name="UserCannotKickMembers" xml:space="preserve">
<value>You cannot kick members from this guild!</value> <value>You cannot kick members from this guild!</value>
</data> </data>
<data name="UserCannotModerateMembers" xml:space="preserve"> <data name="UserCannotMuteMembers" xml:space="preserve">
<value>You cannot moderate members in this guild!</value> <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>
<data name="UserCannotManageGuild" xml:space="preserve"> <data name="UserCannotManageGuild" xml:space="preserve">
<value>You cannot manage this guild!</value> <value>You cannot manage this guild!</value>
@ -585,6 +588,12 @@
<data name="ButtonReportIssue" xml:space="preserve"> <data name="ButtonReportIssue" xml:space="preserve">
<value>Report an issue</value> <value>Report an issue</value>
</data> </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"> <data name="InvalidTimeSpan" xml:space="preserve">
<value>Time specified incorrectly!</value> <value>Time specified incorrectly!</value>
</data> </data>
@ -654,4 +663,25 @@
<data name="EightBallNegative5" xml:space="preserve"> <data name="EightBallNegative5" xml:space="preserve">
<value>Very doubtful</value> <value>Very doubtful</value>
</data> </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>
<data name="SettingValueEquals" xml:space="preserve">
<value>The setting value is the same as the input value.</value>
</data>
</root> </root>

View file

@ -117,13 +117,13 @@
<data name="DefaultWelcomeMessage" xml:space="preserve"> <data name="DefaultWelcomeMessage" xml:space="preserve">
<value>{0}, добро пожаловать на сервер {1}</value> <value>{0}, добро пожаловать на сервер {1}</value>
</data> </data>
<data name="Sound1" xml:space="preserve"> <data name="Generic1" xml:space="preserve">
<value>Виимо!</value> <value>Виимо!</value>
</data> </data>
<data name="Sound2" xml:space="preserve"> <data name="Generic2" xml:space="preserve">
<value>Вууми!</value> <value>Вууми!</value>
</data> </data>
<data name="Sound3" xml:space="preserve"> <data name="Generic3" xml:space="preserve">
<value>Нгьес!</value> <value>Нгьес!</value>
</data> </data>
<data name="PunishmentExpired" xml:space="preserve"> <data name="PunishmentExpired" xml:space="preserve">
@ -228,8 +228,11 @@
<data name="UserCannotKickMembers" xml:space="preserve"> <data name="UserCannotKickMembers" xml:space="preserve">
<value>Ты не можешь выгонять участников с этого сервера!</value> <value>Ты не можешь выгонять участников с этого сервера!</value>
</data> </data>
<data name="UserCannotModerateMembers" xml:space="preserve"> <data name="UserCannotMuteMembers" xml:space="preserve">
<value>Ты не можешь модерировать участников этого сервера!</value> <value>Ты не можешь глушить участников этого сервера!</value>
</data>
<data name="UserCannotUnmuteMembers" xml:space="preserve">
<value>Ты не можешь разглушать участников этого сервера!</value>
</data> </data>
<data name="UserCannotManageGuild" xml:space="preserve"> <data name="UserCannotManageGuild" xml:space="preserve">
<value>Ты не можешь настраивать этот сервер!</value> <value>Ты не можешь настраивать этот сервер!</value>
@ -585,6 +588,12 @@
<data name="ButtonReportIssue" xml:space="preserve"> <data name="ButtonReportIssue" xml:space="preserve">
<value>Сообщить о проблеме</value> <value>Сообщить о проблеме</value>
</data> </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"> <data name="InvalidTimeSpan" xml:space="preserve">
<value>Неправильно указано время!</value> <value>Неправильно указано время!</value>
</data> </data>
@ -654,4 +663,25 @@
<data name="EightBallNegative5" xml:space="preserve"> <data name="EightBallNegative5" xml:space="preserve">
<value>Весьма сомнительно</value> <value>Весьма сомнительно</value>
</data> </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>
<data name="SettingValueEquals" xml:space="preserve">
<value>Значение настройки такое же, как и вводное значение.</value>
</data>
</root> </root>

View file

@ -4,7 +4,7 @@ using JetBrains.Annotations;
using Remora.Commands.Parsers; using Remora.Commands.Parsers;
using Remora.Results; using Remora.Results;
namespace Octobot.Parsers; namespace TeamOctolings.Octobot.Parsers;
/// <summary> /// <summary>
/// Parses <see cref="TimeSpan"/>s. /// Parses <see cref="TimeSpan"/>s.

View file

@ -2,12 +2,8 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Octobot.Commands.Events;
using Octobot.Services;
using Octobot.Services.Update;
using Remora.Discord.API.Abstractions.Gateway.Commands; using Remora.Discord.API.Abstractions.Gateway.Commands;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Objects;
using Remora.Discord.Caching.Extensions; using Remora.Discord.Caching.Extensions;
using Remora.Discord.Caching.Services; using Remora.Discord.Caching.Services;
using Remora.Discord.Commands.Extensions; using Remora.Discord.Commands.Extensions;
@ -15,23 +11,20 @@ using Remora.Discord.Commands.Services;
using Remora.Discord.Extensions.Extensions; using Remora.Discord.Extensions.Extensions;
using Remora.Discord.Gateway; using Remora.Discord.Gateway;
using Remora.Discord.Hosting.Extensions; using Remora.Discord.Hosting.Extensions;
using Remora.Rest.Core;
using Serilog.Extensions.Logging; using Serilog.Extensions.Logging;
using TeamOctolings.Octobot.Commands.Events;
using TeamOctolings.Octobot.Services;
using TeamOctolings.Octobot.Services.Update;
namespace Octobot; namespace TeamOctolings.Octobot;
public sealed class Octobot public sealed class Program
{ {
public const string RepositoryUrl = "https://github.com/TeamOctolings/Octobot";
public const string IssuesUrl = $"{RepositoryUrl}/issues";
public static readonly AllowedMentions NoMentions = new(
Array.Empty<MentionType>(), Array.Empty<Snowflake>(), Array.Empty<Snowflake>());
public static async Task Main(string[] args) public static async Task Main(string[] args)
{ {
var host = CreateHostBuilder(args).UseConsoleLifetime().Build(); var host = CreateHostBuilder(args).UseConsoleLifetime().Build();
var services = host.Services; var services = host.Services;
Utility.StaticLogger = services.GetRequiredService<ILogger<Program>>();
var slashService = services.GetRequiredService<SlashService>(); var slashService = services.GetRequiredService<SlashService>();
// Providing a guild ID to this call will result in command duplicates! // Providing a guild ID to this call will result in command duplicates!
@ -80,14 +73,15 @@ public sealed class Octobot
// Init // Init
.AddDiscordCaching() .AddDiscordCaching()
.AddDiscordCommands(true, false) .AddDiscordCommands(true, false)
.AddRespondersFromAssembly(typeof(Octobot).Assembly) .AddRespondersFromAssembly(typeof(Program).Assembly)
.AddCommandGroupsFromAssembly(typeof(Octobot).Assembly) .AddCommandGroupsFromAssembly(typeof(Program).Assembly)
// Slash command event handlers // Slash command event handlers
.AddPreparationErrorEvent<LoggingPreparationErrorEvent>() .AddPreparationErrorEvent<LoggingPreparationErrorEvent>()
.AddPostExecutionEvent<ErrorLoggingPostExecutionEvent>() .AddPostExecutionEvent<ErrorLoggingPostExecutionEvent>()
// Services // Services
.AddSingleton<Utility>() .AddSingleton<AccessControlService>()
.AddSingleton<GuildDataService>() .AddSingleton<GuildDataService>()
.AddSingleton<Utility>()
.AddHostedService<GuildDataService>(provider => provider.GetRequiredService<GuildDataService>()) .AddHostedService<GuildDataService>(provider => provider.GetRequiredService<GuildDataService>())
.AddHostedService<MemberUpdateService>() .AddHostedService<MemberUpdateService>()
.AddHostedService<ScheduledEventUpdateService>() .AddHostedService<ScheduledEventUpdateService>()

View file

@ -1,8 +1,5 @@
using JetBrains.Annotations; using JetBrains.Annotations;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services;
using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Abstractions.Rest;
@ -11,15 +8,18 @@ using Remora.Discord.API.Objects;
using Remora.Discord.Extensions.Embeds; using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway.Responders; using Remora.Discord.Gateway.Responders;
using Remora.Results; using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace Octobot.Responders; namespace TeamOctolings.Octobot.Responders;
/// <summary> /// <summary>
/// Handles sending a <see cref="Ready" /> message to a guild that has just initialized if that guild /// Handles sending a <see cref="Ready" /> message to a guild that has just initialized if that guild
/// has <see cref="GuildSettings.ReceiveStartupMessages" /> enabled /// has <see cref="GuildSettings.ReceiveStartupMessages" /> enabled
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class GuildLoadedResponder : IResponder<IGuildCreate> public sealed class GuildLoadedResponder : IResponder<IGuildCreate>
{ {
private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestChannelAPI _channelApi;
private readonly GuildDataService _guildData; private readonly GuildDataService _guildData;
@ -42,7 +42,7 @@ public class GuildLoadedResponder : IResponder<IGuildCreate>
{ {
if (!gatewayEvent.Guild.IsT0) // Guild is not IAvailableGuild if (!gatewayEvent.Guild.IsT0) // Guild is not IAvailableGuild
{ {
return Result.FromSuccess(); return Result.Success;
} }
var guild = gatewayEvent.Guild.AsT0; var guild = gatewayEvent.Guild.AsT0;
@ -57,7 +57,7 @@ public class GuildLoadedResponder : IResponder<IGuildCreate>
var botResult = await _userApi.GetCurrentUserAsync(ct); var botResult = await _userApi.GetCurrentUserAsync(ct);
if (!botResult.IsDefined(out var bot)) if (!botResult.IsDefined(out var bot))
{ {
return Result.FromError(botResult); return ResultExtensions.FromError(botResult);
} }
if (data.DataLoadFailed) if (data.DataLoadFailed)
@ -68,27 +68,23 @@ public class GuildLoadedResponder : IResponder<IGuildCreate>
var ownerResult = await _userApi.GetUserAsync(guild.OwnerID, ct); var ownerResult = await _userApi.GetUserAsync(guild.OwnerID, ct);
if (!ownerResult.IsDefined(out var owner)) if (!ownerResult.IsDefined(out var owner))
{ {
return Result.FromError(ownerResult); return ResultExtensions.FromError(ownerResult);
} }
_logger.LogInformation("Loaded guild \"{Name}\" ({ID}) owned by {Owner} ({OwnerID}) with {MemberCount} members", _logger.LogInformation("Loaded guild \"{Name}\" ({ID}) owned by {Owner} ({OwnerID}) with {MemberCount} members",
guild.Name, guild.ID, owner.GetTag(), owner.ID, guild.MemberCount); guild.Name, guild.ID, owner.GetTag(), owner.ID, guild.MemberCount);
if (!GuildSettings.ReceiveStartupMessages.Get(cfg)) if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()
|| !GuildSettings.ReceiveStartupMessages.Get(cfg))
{ {
return Result.FromSuccess(); return Result.Success;
}
if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty())
{
return Result.FromSuccess();
} }
Messages.Culture = GuildSettings.Language.Get(cfg); Messages.Culture = GuildSettings.Language.Get(cfg);
var i = Random.Shared.Next(1, 4); var i = Random.Shared.Next(1, 4);
var embed = new EmbedBuilder().WithSmallTitle(bot.GetTag(), bot) var embed = new EmbedBuilder().WithSmallTitle(bot.GetTag(), bot)
.WithTitle($"Sound{i}".Localized()) .WithTitle($"Generic{i}".Localized())
.WithDescription(Messages.Ready) .WithDescription(Messages.Ready)
.WithCurrentTimestamp() .WithCurrentTimestamp()
.WithColour(ColorsList.Blue) .WithColour(ColorsList.Blue)
@ -98,12 +94,12 @@ public class GuildLoadedResponder : IResponder<IGuildCreate>
GuildSettings.PrivateFeedbackChannel.Get(cfg), embedResult: embed, ct: ct); GuildSettings.PrivateFeedbackChannel.Get(cfg), embedResult: embed, ct: ct);
} }
private async Task<Result> SendDataLoadFailed(IGuild guild, GuildData data, IUser bot, CancellationToken ct) private async Task<Result> SendDataLoadFailed(IGuild guild, GuildData data, IUser bot, CancellationToken ct = default)
{ {
var channelResult = await _utility.GetEmergencyFeedbackChannel(guild, data, ct); var channelResult = await _utility.GetEmergencyFeedbackChannel(guild, data, ct);
if (!channelResult.IsDefined(out var channel)) if (!channelResult.IsDefined(out var channel))
{ {
return Result.FromError(channelResult); return ResultExtensions.FromError(channelResult);
} }
var errorEmbed = new EmbedBuilder() var errorEmbed = new EmbedBuilder()
@ -115,9 +111,12 @@ public class GuildLoadedResponder : IResponder<IGuildCreate>
var issuesButton = new ButtonComponent( var issuesButton = new ButtonComponent(
ButtonComponentStyle.Link, ButtonComponentStyle.Link,
Messages.ButtonReportIssue, BuildInfo.IsDirty
new PartialEmoji(Name: "⚠️"), ? Messages.ButtonDirty
URL: Octobot.IssuesUrl : Messages.ButtonReportIssue,
new PartialEmoji(Name: "\u26a0\ufe0f"), // 'WARNING SIGN' (U+26A0)
URL: BuildInfo.IssuesUrl,
IsDisabled: BuildInfo.IsDirty
); );
return await _channelApi.CreateMessageWithEmbedResultAsync(channel, embedResult: errorEmbed, return await _channelApi.CreateMessageWithEmbedResultAsync(channel, embedResult: errorEmbed,

View file

@ -1,16 +1,16 @@
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using JetBrains.Annotations; using JetBrains.Annotations;
using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services;
using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Extensions.Embeds; using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway.Responders; using Remora.Discord.Gateway.Responders;
using Remora.Rest.Core; using Remora.Rest.Core;
using Remora.Results; using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace Octobot.Responders; namespace TeamOctolings.Octobot.Responders;
/// <summary> /// <summary>
/// Handles sending a guild's <see cref="GuildSettings.WelcomeMessage" /> if one is set. /// Handles sending a guild's <see cref="GuildSettings.WelcomeMessage" /> if one is set.
@ -18,7 +18,7 @@ namespace Octobot.Responders;
/// </summary> /// </summary>
/// <seealso cref="GuildSettings.WelcomeMessage" /> /// <seealso cref="GuildSettings.WelcomeMessage" />
[UsedImplicitly] [UsedImplicitly]
public class GuildMemberJoinedResponder : IResponder<IGuildMemberAdd> public sealed class GuildMemberJoinedResponder : IResponder<IGuildMemberAdd>
{ {
private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestChannelAPI _channelApi;
private readonly IDiscordRestGuildAPI _guildApi; private readonly IDiscordRestGuildAPI _guildApi;
@ -48,13 +48,13 @@ public class GuildMemberJoinedResponder : IResponder<IGuildMemberAdd>
var returnRolesResult = await TryReturnRolesAsync(cfg, memberData, gatewayEvent.GuildID, user.ID, ct); var returnRolesResult = await TryReturnRolesAsync(cfg, memberData, gatewayEvent.GuildID, user.ID, ct);
if (!returnRolesResult.IsSuccess) if (!returnRolesResult.IsSuccess)
{ {
return Result.FromError(returnRolesResult.Error); return ResultExtensions.FromError(returnRolesResult);
} }
if (GuildSettings.PublicFeedbackChannel.Get(cfg).Empty() if (GuildSettings.WelcomeMessagesChannel.Get(cfg).Empty()
|| GuildSettings.WelcomeMessage.Get(cfg) is "off" or "disable" or "disabled") || GuildSettings.WelcomeMessage.Get(cfg) is "off" or "disable" or "disabled")
{ {
return Result.FromSuccess(); return Result.Success;
} }
Messages.Culture = GuildSettings.Language.Get(cfg); Messages.Culture = GuildSettings.Language.Get(cfg);
@ -65,7 +65,7 @@ public class GuildMemberJoinedResponder : IResponder<IGuildMemberAdd>
var guildResult = await _guildApi.GetGuildAsync(gatewayEvent.GuildID, ct: ct); var guildResult = await _guildApi.GetGuildAsync(gatewayEvent.GuildID, ct: ct);
if (!guildResult.IsDefined(out var guild)) if (!guildResult.IsDefined(out var guild))
{ {
return Result.FromError(guildResult); return ResultExtensions.FromError(guildResult);
} }
var embed = new EmbedBuilder() var embed = new EmbedBuilder()
@ -76,16 +76,16 @@ public class GuildMemberJoinedResponder : IResponder<IGuildMemberAdd>
.Build(); .Build();
return await _channelApi.CreateMessageWithEmbedResultAsync( return await _channelApi.CreateMessageWithEmbedResultAsync(
GuildSettings.PublicFeedbackChannel.Get(cfg), embedResult: embed, GuildSettings.WelcomeMessagesChannel.Get(cfg), embedResult: embed,
allowedMentions: Octobot.NoMentions, ct: ct); allowedMentions: Utility.NoMentions, ct: ct);
} }
private async Task<Result> TryReturnRolesAsync( private async Task<Result> TryReturnRolesAsync(
JsonNode cfg, MemberData memberData, Snowflake guildId, Snowflake userId, CancellationToken ct) JsonNode cfg, MemberData memberData, Snowflake guildId, Snowflake userId, CancellationToken ct = default)
{ {
if (!GuildSettings.ReturnRolesOnRejoin.Get(cfg)) if (!GuildSettings.ReturnRolesOnRejoin.Get(cfg))
{ {
return Result.FromSuccess(); return Result.Success;
} }
var assignRoles = new List<Snowflake>(); var assignRoles = new List<Snowflake>();

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 sealed 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

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

View file

@ -1,8 +1,5 @@
using System.Text; using System.Text;
using JetBrains.Annotations; using JetBrains.Annotations;
using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services;
using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Abstractions.Rest;
@ -10,15 +7,18 @@ using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting; using Remora.Discord.Extensions.Formatting;
using Remora.Discord.Gateway.Responders; using Remora.Discord.Gateway.Responders;
using Remora.Results; using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace Octobot.Responders; namespace TeamOctolings.Octobot.Responders;
/// <summary> /// <summary>
/// Handles logging the contents of a deleted message and the user who deleted the message /// 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. /// to a guild's <see cref="GuildSettings.PrivateFeedbackChannel" /> if one is set.
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class MessageDeletedResponder : IResponder<IMessageDelete> public sealed class MessageDeletedResponder : IResponder<IMessageDelete>
{ {
private readonly IDiscordRestAuditLogAPI _auditLogApi; private readonly IDiscordRestAuditLogAPI _auditLogApi;
private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestChannelAPI _channelApi;
@ -39,37 +39,37 @@ public class MessageDeletedResponder : IResponder<IMessageDelete>
{ {
if (!gatewayEvent.GuildID.IsDefined(out var guildId)) if (!gatewayEvent.GuildID.IsDefined(out var guildId))
{ {
return Result.FromSuccess(); return Result.Success;
} }
var cfg = await _guildData.GetSettings(guildId, ct); var cfg = await _guildData.GetSettings(guildId, ct);
if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()) if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty())
{ {
return Result.FromSuccess(); return Result.Success;
} }
var messageResult = await _channelApi.GetChannelMessageAsync(gatewayEvent.ChannelID, gatewayEvent.ID, ct); var messageResult = await _channelApi.GetChannelMessageAsync(gatewayEvent.ChannelID, gatewayEvent.ID, ct);
if (!messageResult.IsDefined(out var message)) if (!messageResult.IsDefined(out var message))
{ {
return Result.FromError(messageResult); return ResultExtensions.FromError(messageResult);
} }
if (string.IsNullOrWhiteSpace(message.Content)) if (string.IsNullOrWhiteSpace(message.Content))
{ {
return Result.FromSuccess(); return Result.Success;
} }
var auditLogResult = await _auditLogApi.GetGuildAuditLogAsync( var auditLogResult = await _auditLogApi.GetGuildAuditLogAsync(
guildId, actionType: AuditLogEvent.MessageDelete, limit: 1, ct: ct); guildId, actionType: AuditLogEvent.MessageDelete, limit: 1, ct: ct);
if (!auditLogResult.IsDefined(out var auditLogPage)) if (!auditLogResult.IsDefined(out var auditLogPage))
{ {
return Result.FromError(auditLogResult); return ResultExtensions.FromError(auditLogResult);
} }
var auditLog = auditLogPage.AuditLogEntries.Single();
var deleterResult = Result<IUser>.FromSuccess(message.Author); var deleterResult = Result<IUser>.FromSuccess(message.Author);
if (auditLog.UserID is not null
var auditLog = auditLogPage.AuditLogEntries.SingleOrDefault();
if (auditLog is { UserID: not null }
&& auditLog.Options.Value.ChannelID == gatewayEvent.ChannelID && auditLog.Options.Value.ChannelID == gatewayEvent.ChannelID
&& DateTimeOffset.UtcNow.Subtract(auditLog.ID.Timestamp).TotalSeconds <= 2) && DateTimeOffset.UtcNow.Subtract(auditLog.ID.Timestamp).TotalSeconds <= 2)
{ {
@ -78,15 +78,16 @@ public class MessageDeletedResponder : IResponder<IMessageDelete>
if (!deleterResult.IsDefined(out var deleter)) if (!deleterResult.IsDefined(out var deleter))
{ {
return Result.FromError(deleterResult); return ResultExtensions.FromError(deleterResult);
} }
Messages.Culture = GuildSettings.Language.Get(cfg); Messages.Culture = GuildSettings.Language.Get(cfg);
var builder = new StringBuilder().AppendLine( var builder = new StringBuilder()
string.Format(Messages.DescriptionActionJumpToChannel, .AppendLine(message.Content.InBlockCode())
Mention.Channel(gatewayEvent.ChannelID))) .AppendLine(
.AppendLine(message.Content.InBlockCode()); string.Format(Messages.DescriptionActionJumpToChannel, Mention.Channel(gatewayEvent.ChannelID))
);
var embed = new EmbedBuilder() var embed = new EmbedBuilder()
.WithSmallTitle( .WithSmallTitle(
@ -101,6 +102,6 @@ public class MessageDeletedResponder : IResponder<IMessageDelete>
return await _channelApi.CreateMessageWithEmbedResultAsync( return await _channelApi.CreateMessageWithEmbedResultAsync(
GuildSettings.PrivateFeedbackChannel.Get(cfg), embedResult: embed, GuildSettings.PrivateFeedbackChannel.Get(cfg), embedResult: embed,
allowedMentions: Octobot.NoMentions, ct: ct); allowedMentions: Utility.NoMentions, ct: ct);
} }
} }

View file

@ -1,9 +1,6 @@
using System.Text; using System.Text;
using DiffPlex.DiffBuilder; using DiffPlex.DiffBuilder;
using JetBrains.Annotations; using JetBrains.Annotations;
using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services;
using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Abstractions.Rest;
@ -12,15 +9,18 @@ using Remora.Discord.Caching.Services;
using Remora.Discord.Extensions.Embeds; using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway.Responders; using Remora.Discord.Gateway.Responders;
using Remora.Results; using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace Octobot.Responders; namespace TeamOctolings.Octobot.Responders;
/// <summary> /// <summary>
/// Handles logging the difference between an edited message's old and new content /// 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. /// to a guild's <see cref="GuildSettings.PrivateFeedbackChannel" /> if one is set.
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class MessageEditedResponder : IResponder<IMessageUpdate> public sealed class MessageEditedResponder : IResponder<IMessageUpdate>
{ {
private readonly CacheService _cacheService; private readonly CacheService _cacheService;
private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestChannelAPI _channelApi;
@ -46,30 +46,18 @@ public class MessageEditedResponder : IResponder<IMessageUpdate>
return new ArgumentNullError(nameof(gatewayEvent.ChannelID)); return new ArgumentNullError(nameof(gatewayEvent.ChannelID));
} }
if (!gatewayEvent.GuildID.IsDefined(out var guildId)) 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.FromSuccess(); return Result.Success;
}
if (gatewayEvent.Author.IsDefined(out var author) && author.IsBot.OrDefault(false))
{
return Result.FromSuccess();
}
if (!gatewayEvent.EditedTimestamp.IsDefined(out var timestamp))
{
return Result.FromSuccess(); // The message wasn't actually edited
}
if (!gatewayEvent.Content.IsDefined(out var newContent))
{
return Result.FromSuccess();
} }
var cfg = await _guildData.GetSettings(guildId, ct); var cfg = await _guildData.GetSettings(guildId, ct);
if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()) if (author.IsBot.OrDefault(false) || GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty())
{ {
return Result.FromSuccess(); return Result.Success;
} }
var cacheKey = new KeyHelpers.MessageCacheKey(channelId, messageId); var cacheKey = new KeyHelpers.MessageCacheKey(channelId, messageId);
@ -78,12 +66,12 @@ public class MessageEditedResponder : IResponder<IMessageUpdate>
if (!messageResult.IsDefined(out var message)) if (!messageResult.IsDefined(out var message))
{ {
_ = _channelApi.GetChannelMessageAsync(channelId, messageId, ct); _ = _channelApi.GetChannelMessageAsync(channelId, messageId, ct);
return Result.FromSuccess(); return Result.Success;
} }
if (message.Content == newContent) if (message.Content == newContent)
{ {
return Result.FromSuccess(); return Result.Success;
} }
// Custom event responders are called earlier than responders responsible for message caching // Custom event responders are called earlier than responders responsible for message caching
@ -101,10 +89,11 @@ public class MessageEditedResponder : IResponder<IMessageUpdate>
Messages.Culture = GuildSettings.Language.Get(cfg); Messages.Culture = GuildSettings.Language.Get(cfg);
var builder = new StringBuilder().AppendLine( var builder = new StringBuilder()
string.Format(Messages.DescriptionActionJumpToMessage, .AppendLine(diff.AsMarkdown())
$"https://discord.com/channels/{guildId}/{channelId}/{messageId}")) .AppendLine(string.Format(Messages.DescriptionActionJumpToMessage,
.AppendLine(diff.AsMarkdown()); $"https://discord.com/channels/{guildId}/{channelId}/{messageId}")
);
var embed = new EmbedBuilder() var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(Messages.CachedMessageEdited, message.Author.GetTag()), message.Author) .WithSmallTitle(string.Format(Messages.CachedMessageEdited, message.Author.GetTag()), message.Author)
@ -115,6 +104,6 @@ public class MessageEditedResponder : IResponder<IMessageUpdate>
return await _channelApi.CreateMessageWithEmbedResultAsync( return await _channelApi.CreateMessageWithEmbedResultAsync(
GuildSettings.PrivateFeedbackChannel.Get(cfg), embedResult: embed, GuildSettings.PrivateFeedbackChannel.Get(cfg), embedResult: embed,
allowedMentions: Octobot.NoMentions, ct: ct); allowedMentions: Utility.NoMentions, ct: ct);
} }
} }

View file

@ -5,13 +5,13 @@ using Remora.Discord.Gateway.Responders;
using Remora.Rest.Core; using Remora.Rest.Core;
using Remora.Results; using Remora.Results;
namespace Octobot.Responders; namespace TeamOctolings.Octobot.Responders;
/// <summary> /// <summary>
/// Handles sending replies to easter egg messages. /// Handles sending replies to easter egg messages.
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class MessageCreateResponder : IResponder<IMessageCreate> public sealed class MessageCreateResponder : IResponder<IMessageCreate>
{ {
private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestChannelAPI _channelApi;
@ -34,6 +34,6 @@ public class MessageCreateResponder : IResponder<IMessageCreate>
"лан" => "https://i.ibb.co/VYH2QLc/lan.jpg", "лан" => "https://i.ibb.co/VYH2QLc/lan.jpg",
_ => default(Optional<string>) _ => default(Optional<string>)
}); });
return Task.FromResult(Result.FromSuccess()); return Task.FromResult(Result.Success);
} }
} }

View file

@ -0,0 +1,142 @@
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, MemberData memberData,
DiscordPermission permission)
{
var moderatorRole = GuildSettings.ModeratorRole.Get(data.Settings);
if (!moderatorRole.Empty() && memberData.Roles.Contains(moderatorRole.Value))
{
return true;
}
return roles
.Where(r => memberData.Roles.Contains(r.ID.Value))
.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 rolesResult = await _guildApi.GetGuildRolesAsync(guildId, ct);
if (!rolesResult.IsDefined(out var roles))
{
return Result<string?>.FromError(rolesResult);
}
var data = await _data.GetData(guildId, ct);
var targetData = data.GetOrCreateMemberData(targetId);
var botData = data.GetOrCreateMemberData(bot.ID);
if (interacterId is null)
{
return CheckInteractions(action, guild, roles, targetData, botData, botData);
}
var interacterData = data.GetOrCreateMemberData(interacterId.Value);
var hasPermission = CheckPermission(roles, data, interacterData,
action switch
{
"Ban" => DiscordPermission.BanMembers,
"Kick" => DiscordPermission.KickMembers,
"Mute" or "Unmute" => DiscordPermission.ModerateMembers,
_ => throw new Exception()
});
return hasPermission
? CheckInteractions(action, guild, roles, targetData, botData, interacterData)
: Result<string?>.FromSuccess($"UserCannot{action}Members".Localized());
}
private static Result<string?> CheckInteractions(
string action, IGuild guild, IReadOnlyList<IRole> roles, MemberData targetData, MemberData botData,
MemberData interacterData)
{
if (botData.Id == targetData.Id)
{
return Result<string?>.FromSuccess($"UserCannot{action}Bot".Localized());
}
if (targetData.Id == guild.OwnerID)
{
return Result<string?>.FromSuccess($"UserCannot{action}Owner".Localized());
}
var targetRoles = roles.Where(r => targetData.Roles.Contains(r.ID.Value)).ToList();
var botRoles = roles.Where(r => botData.Roles.Contains(r.ID.Value));
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 => interacterData.Roles.Contains(r.ID.Value));
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

@ -3,10 +3,10 @@ using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Octobot.Data;
using Remora.Rest.Core; using Remora.Rest.Core;
using TeamOctolings.Octobot.Data;
namespace Octobot.Services; namespace TeamOctolings.Octobot.Services;
/// <summary> /// <summary>
/// Handles saving, loading, initializing and providing <see cref="GuildData" />. /// Handles saving, loading, initializing and providing <see cref="GuildData" />.
@ -27,7 +27,7 @@ public sealed class GuildDataService : BackgroundService
return SaveAsync(ct); return SaveAsync(ct);
} }
private Task SaveAsync(CancellationToken ct) private Task SaveAsync(CancellationToken ct = default)
{ {
var tasks = new List<Task>(); var tasks = new List<Task>();
var datas = _datas.Values.ToArray(); var datas = _datas.Values.ToArray();
@ -44,7 +44,7 @@ public sealed class GuildDataService : BackgroundService
return Task.WhenAll(tasks); return Task.WhenAll(tasks);
} }
private static async Task SerializeObjectSafelyAsync<T>(T obj, string path, CancellationToken ct) private static async Task SerializeObjectSafelyAsync<T>(T obj, string path, CancellationToken ct = default)
{ {
var tempFilePath = path + ".tmp"; var tempFilePath = path + ".tmp";
await using (var tempFileStream = File.Create(tempFilePath)) await using (var tempFileStream = File.Create(tempFilePath))
@ -78,7 +78,7 @@ public sealed class GuildDataService : BackgroundService
var settingsPath = $"{path}/Settings.json"; var settingsPath = $"{path}/Settings.json";
var scheduledEventsPath = $"{path}/ScheduledEvents.json"; var scheduledEventsPath = $"{path}/ScheduledEvents.json";
MigrateGuildData(guildId, path); MigrateDataDirectory(guildId, path);
Directory.CreateDirectory(path); Directory.CreateDirectory(path);
@ -106,6 +106,11 @@ public sealed class GuildDataService : BackgroundService
dataLoadFailed = true; dataLoadFailed = true;
} }
if (jsonSettings is not null)
{
FixJsonSettings(jsonSettings);
}
await using var eventsStream = File.OpenRead(scheduledEventsPath); await using var eventsStream = File.OpenRead(scheduledEventsPath);
Dictionary<ulong, ScheduledEventData>? events = null; Dictionary<ulong, ScheduledEventData>? events = null;
try try
@ -155,7 +160,7 @@ public sealed class GuildDataService : BackgroundService
return finalData; return finalData;
} }
private void MigrateGuildData(Snowflake guildId, string newPath) private void MigrateDataDirectory(Snowflake guildId, string newPath)
{ {
var oldPath = $"{guildId}"; var oldPath = $"{guildId}";
@ -169,6 +174,15 @@ public sealed class GuildDataService : BackgroundService
} }
} }
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) public async Task<JsonNode> GetSettings(Snowflake guildId, CancellationToken ct = default)
{ {
return (await GetData(guildId, ct)).Settings; return (await GetData(guildId, ct)).Settings;

View file

@ -2,16 +2,16 @@ using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Octobot.Data;
using Octobot.Extensions;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Extensions.Embeds; using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting; using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core; using Remora.Rest.Core;
using Remora.Results; using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
namespace Octobot.Services.Update; namespace TeamOctolings.Octobot.Services.Update;
public sealed partial class MemberUpdateService : BackgroundService public sealed partial class MemberUpdateService : BackgroundService
{ {
@ -26,20 +26,20 @@ public sealed partial class MemberUpdateService : BackgroundService
"Torus", "Violet", "Vortex", "Vulture", "Wagon", "Whale", "Woodpecker", "Zebra", "Zigzag" "Torus", "Violet", "Vortex", "Vulture", "Wagon", "Whale", "Woodpecker", "Zebra", "Zigzag"
]; ];
private readonly AccessControlService _access;
private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestChannelAPI _channelApi;
private readonly IDiscordRestGuildAPI _guildApi; private readonly IDiscordRestGuildAPI _guildApi;
private readonly GuildDataService _guildData; private readonly GuildDataService _guildData;
private readonly ILogger<MemberUpdateService> _logger; private readonly ILogger<MemberUpdateService> _logger;
private readonly Utility _utility;
public MemberUpdateService(IDiscordRestChannelAPI channelApi, IDiscordRestGuildAPI guildApi, public MemberUpdateService(AccessControlService access, IDiscordRestChannelAPI channelApi,
GuildDataService guildData, ILogger<MemberUpdateService> logger, Utility utility) IDiscordRestGuildAPI guildApi, GuildDataService guildData, ILogger<MemberUpdateService> logger)
{ {
_access = access;
_channelApi = channelApi; _channelApi = channelApi;
_guildApi = guildApi; _guildApi = guildApi;
_guildData = guildData; _guildData = guildData;
_logger = logger; _logger = logger;
_utility = utility;
} }
protected override async Task ExecuteAsync(CancellationToken ct) protected override async Task ExecuteAsync(CancellationToken ct)
@ -62,7 +62,7 @@ public sealed partial class MemberUpdateService : BackgroundService
} }
} }
private async Task<Result> TickMemberDatasAsync(Snowflake guildId, CancellationToken ct) private async Task<Result> TickMemberDatasAsync(Snowflake guildId, CancellationToken ct = default)
{ {
var guildData = await _guildData.GetData(guildId, ct); var guildData = await _guildData.GetData(guildId, ct);
var defaultRole = GuildSettings.DefaultRole.Get(guildData.Settings); var defaultRole = GuildSettings.DefaultRole.Get(guildData.Settings);
@ -79,7 +79,7 @@ public sealed partial class MemberUpdateService : BackgroundService
private async Task<Result> TickMemberDataAsync(Snowflake guildId, GuildData guildData, Snowflake defaultRole, private async Task<Result> TickMemberDataAsync(Snowflake guildId, GuildData guildData, Snowflake defaultRole,
MemberData data, MemberData data,
CancellationToken ct) CancellationToken ct = default)
{ {
var failedResults = new List<Result>(); var failedResults = new List<Result>();
var id = data.Id.ToSnowflake(); var id = data.Id.ToSnowflake();
@ -94,10 +94,10 @@ public sealed partial class MemberUpdateService : BackgroundService
} }
var interactionResult var interactionResult
= await _utility.CheckInteractionsAsync(guildId, null, id, "Update", ct); = await _access.CheckInteractionsAsync(guildId, null, id, "Update", ct);
if (!interactionResult.IsSuccess) if (!interactionResult.IsSuccess)
{ {
return Result.FromError(interactionResult); return ResultExtensions.FromError(interactionResult);
} }
var canInteract = interactionResult.Entity is null; var canInteract = interactionResult.Entity is null;
@ -121,7 +121,7 @@ public sealed partial class MemberUpdateService : BackgroundService
if (!canInteract) if (!canInteract)
{ {
return Result.FromSuccess(); return Result.Success;
} }
var autoUnmuteResult = await TryAutoUnmuteAsync(guildId, id, data, ct); var autoUnmuteResult = await TryAutoUnmuteAsync(guildId, id, data, ct);
@ -144,11 +144,18 @@ public sealed partial class MemberUpdateService : BackgroundService
} }
private async Task<Result> TryAutoUnbanAsync( private async Task<Result> TryAutoUnbanAsync(
Snowflake guildId, Snowflake id, MemberData data, CancellationToken ct) Snowflake guildId, Snowflake id, MemberData data, CancellationToken ct = default)
{ {
if (data.BannedUntil is null || DateTimeOffset.UtcNow <= data.BannedUntil) if (data.BannedUntil is null || DateTimeOffset.UtcNow <= data.BannedUntil)
{ {
return Result.FromSuccess(); 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( var unbanResult = await _guildApi.RemoveGuildBanAsync(
@ -162,11 +169,11 @@ public sealed partial class MemberUpdateService : BackgroundService
} }
private async Task<Result> TryAutoUnmuteAsync( private async Task<Result> TryAutoUnmuteAsync(
Snowflake guildId, Snowflake id, MemberData data, CancellationToken ct) Snowflake guildId, Snowflake id, MemberData data, CancellationToken ct = default)
{ {
if (data.MutedUntil is null || DateTimeOffset.UtcNow <= data.MutedUntil) if (data.MutedUntil is null || DateTimeOffset.UtcNow <= data.MutedUntil)
{ {
return Result.FromSuccess(); return Result.Success;
} }
var unmuteResult = await _guildApi.ModifyGuildMemberAsync( var unmuteResult = await _guildApi.ModifyGuildMemberAsync(
@ -181,7 +188,7 @@ public sealed partial class MemberUpdateService : BackgroundService
} }
private async Task<Result> FilterNicknameAsync(Snowflake guildId, IUser user, IGuildMember member, private async Task<Result> FilterNicknameAsync(Snowflake guildId, IUser user, IGuildMember member,
CancellationToken ct) CancellationToken ct = default)
{ {
var currentNickname = member.Nickname.IsDefined(out var nickname) var currentNickname = member.Nickname.IsDefined(out var nickname)
? nickname ? nickname
@ -202,7 +209,7 @@ public sealed partial class MemberUpdateService : BackgroundService
if (!usernameChanged) if (!usernameChanged)
{ {
return Result.FromSuccess(); return Result.Success;
} }
var newNickname = string.Concat(characterList.ToArray()); var newNickname = string.Concat(characterList.ToArray());
@ -219,16 +226,17 @@ public sealed partial class MemberUpdateService : BackgroundService
private static partial Regex IllegalChars(); private static partial Regex IllegalChars();
private async Task<Result> TickReminderAsync(Reminder reminder, IUser user, MemberData data, Snowflake guildId, private async Task<Result> TickReminderAsync(Reminder reminder, IUser user, MemberData data, Snowflake guildId,
CancellationToken ct) CancellationToken ct = default)
{ {
if (DateTimeOffset.UtcNow < reminder.At) if (DateTimeOffset.UtcNow < reminder.At)
{ {
return Result.FromSuccess(); return Result.Success;
} }
var builder = new StringBuilder() var builder = new StringBuilder()
.AppendBulletPointLine(string.Format(Messages.DescriptionReminder, Markdown.InlineCode(reminder.Text))) .AppendLine(MarkdownExtensions.Quote(reminder.Text))
.AppendBulletPointLine(string.Format(Messages.DescriptionActionJumpToMessage, $"https://discord.com/channels/{guildId.Value}/{reminder.ChannelId}/{reminder.MessageId}")); .AppendBulletPointLine(string.Format(Messages.DescriptionActionJumpToMessage,
$"https://discord.com/channels/{guildId.Value}/{reminder.ChannelId}/{reminder.MessageId}"));
var embed = new EmbedBuilder().WithSmallTitle( var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.Reminder, user.GetTag()), user) string.Format(Messages.Reminder, user.GetTag()), user)
@ -240,10 +248,10 @@ public sealed partial class MemberUpdateService : BackgroundService
reminder.ChannelId.ToSnowflake(), Mention.User(user), embedResult: embed, ct: ct); reminder.ChannelId.ToSnowflake(), Mention.User(user), embedResult: embed, ct: ct);
if (!messageResult.IsSuccess) if (!messageResult.IsSuccess)
{ {
return messageResult; return ResultExtensions.FromError(messageResult);
} }
data.Reminders.Remove(reminder); data.Reminders.Remove(reminder);
return Result.FromSuccess(); return Result.Success;
} }
} }

View file

@ -1,8 +1,6 @@
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Octobot.Data;
using Octobot.Extensions;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects; using Remora.Discord.API.Objects;
@ -10,8 +8,10 @@ using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting; using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core; using Remora.Rest.Core;
using Remora.Results; using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
namespace Octobot.Services.Update; namespace TeamOctolings.Octobot.Services.Update;
public sealed class ScheduledEventUpdateService : BackgroundService public sealed class ScheduledEventUpdateService : BackgroundService
{ {
@ -46,14 +46,14 @@ public sealed class ScheduledEventUpdateService : BackgroundService
} }
} }
private async Task<Result> TickScheduledEventsAsync(Snowflake guildId, CancellationToken ct) private async Task<Result> TickScheduledEventsAsync(Snowflake guildId, CancellationToken ct = default)
{ {
var failedResults = new List<Result>(); var failedResults = new List<Result>();
var data = await _guildData.GetData(guildId, ct); var data = await _guildData.GetData(guildId, ct);
var eventsResult = await _eventApi.ListScheduledEventsForGuildAsync(guildId, ct: ct); var eventsResult = await _eventApi.ListScheduledEventsForGuildAsync(guildId, ct: ct);
if (!eventsResult.IsDefined(out var events)) if (!eventsResult.IsDefined(out var events))
{ {
return Result.FromError(eventsResult); return ResultExtensions.FromError(eventsResult);
} }
SyncScheduledEvents(data, events); SyncScheduledEvents(data, events);
@ -133,7 +133,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService
private async Task<Result> TickScheduledEventAsync( private async Task<Result> TickScheduledEventAsync(
Snowflake guildId, GuildData data, IGuildScheduledEvent scheduledEvent, ScheduledEventData eventData, Snowflake guildId, GuildData data, IGuildScheduledEvent scheduledEvent, ScheduledEventData eventData,
CancellationToken ct) CancellationToken ct = default)
{ {
if (GuildSettings.AutoStartEvents.Get(data.Settings) if (GuildSettings.AutoStartEvents.Get(data.Settings)
&& DateTimeOffset.UtcNow >= scheduledEvent.ScheduledStartTime && DateTimeOffset.UtcNow >= scheduledEvent.ScheduledStartTime
@ -147,7 +147,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService
|| eventData.EarlyNotificationSent || eventData.EarlyNotificationSent
|| DateTimeOffset.UtcNow < scheduledEvent.ScheduledStartTime - offset) || DateTimeOffset.UtcNow < scheduledEvent.ScheduledStartTime - offset)
{ {
return Result.FromSuccess(); return Result.Success;
} }
var sendResult = await SendEarlyEventNotificationAsync(scheduledEvent, data, ct); var sendResult = await SendEarlyEventNotificationAsync(scheduledEvent, data, ct);
@ -160,7 +160,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService
} }
private async Task<Result> AutoStartEventAsync( private async Task<Result> AutoStartEventAsync(
Snowflake guildId, IGuildScheduledEvent scheduledEvent, CancellationToken ct) Snowflake guildId, IGuildScheduledEvent scheduledEvent, CancellationToken ct = default)
{ {
return (Result)await _eventApi.ModifyGuildScheduledEventAsync( return (Result)await _eventApi.ModifyGuildScheduledEventAsync(
guildId, scheduledEvent.ID, guildId, scheduledEvent.ID,
@ -182,7 +182,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService
{ {
if (GuildSettings.EventNotificationChannel.Get(settings).Empty()) if (GuildSettings.EventNotificationChannel.Get(settings).Empty())
{ {
return Result.FromSuccess(); return Result.Success;
} }
if (!scheduledEvent.Creator.IsDefined(out var creator)) if (!scheduledEvent.Creator.IsDefined(out var creator))
@ -204,7 +204,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService
if (!embedDescriptionResult.IsDefined(out var embedDescription)) if (!embedDescriptionResult.IsDefined(out var embedDescription))
{ {
return Result.FromError(embedDescriptionResult); return ResultExtensions.FromError(embedDescriptionResult);
} }
var embed = new EmbedBuilder() var embed = new EmbedBuilder()
@ -223,7 +223,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService
var button = new ButtonComponent( var button = new ButtonComponent(
ButtonComponentStyle.Link, ButtonComponentStyle.Link,
Messages.ButtonOpenEventInfo, Messages.ButtonOpenEventInfo,
new PartialEmoji(Name: "📋"), new PartialEmoji(Name: "\ud83d\udccb"), // 'CLIPBOARD' (U+1F4CB)
URL: $"https://discord.com/events/{scheduledEvent.GuildID}/{scheduledEvent.ID}" URL: $"https://discord.com/events/{scheduledEvent.GuildID}/{scheduledEvent.ID}"
); );
@ -283,7 +283,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService
if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty()) if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty())
{ {
return Result.FromSuccess(); return Result.Success;
} }
var embedDescriptionResult = scheduledEvent.EntityType switch var embedDescriptionResult = scheduledEvent.EntityType switch
@ -298,12 +298,12 @@ public sealed class ScheduledEventUpdateService : BackgroundService
scheduledEvent, data, ct); scheduledEvent, data, ct);
if (!contentResult.IsDefined(out var content)) if (!contentResult.IsDefined(out var content))
{ {
return Result.FromError(contentResult); return ResultExtensions.FromError(contentResult);
} }
if (!embedDescriptionResult.IsDefined(out var embedDescription)) if (!embedDescriptionResult.IsDefined(out var embedDescription))
{ {
return Result.FromError(embedDescriptionResult); return ResultExtensions.FromError(embedDescriptionResult);
} }
var startedEmbed = new EmbedBuilder() var startedEmbed = new EmbedBuilder()
@ -319,12 +319,12 @@ public sealed class ScheduledEventUpdateService : BackgroundService
} }
private async Task<Result> SendScheduledEventCompletedMessage(ScheduledEventData eventData, GuildData data, private async Task<Result> SendScheduledEventCompletedMessage(ScheduledEventData eventData, GuildData data,
CancellationToken ct) CancellationToken ct = default)
{ {
if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty()) if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty())
{ {
data.ScheduledEvents.Remove(eventData.Id); data.ScheduledEvents.Remove(eventData.Id);
return Result.FromSuccess(); return Result.Success;
} }
var completedEmbed = new EmbedBuilder() var completedEmbed = new EmbedBuilder()
@ -351,12 +351,12 @@ public sealed class ScheduledEventUpdateService : BackgroundService
} }
private async Task<Result> SendScheduledEventCancelledMessage(ScheduledEventData eventData, GuildData data, private async Task<Result> SendScheduledEventCancelledMessage(ScheduledEventData eventData, GuildData data,
CancellationToken ct) CancellationToken ct = default)
{ {
if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty()) if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty())
{ {
data.ScheduledEvents.Remove(eventData.Id); data.ScheduledEvents.Remove(eventData.Id);
return Result.FromSuccess(); return Result.Success;
} }
var embed = new EmbedBuilder() var embed = new EmbedBuilder()
@ -405,18 +405,18 @@ public sealed class ScheduledEventUpdateService : BackgroundService
} }
private async Task<Result> SendEarlyEventNotificationAsync( private async Task<Result> SendEarlyEventNotificationAsync(
IGuildScheduledEvent scheduledEvent, GuildData data, CancellationToken ct) IGuildScheduledEvent scheduledEvent, GuildData data, CancellationToken ct = default)
{ {
if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty()) if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty())
{ {
return Result.FromSuccess(); return Result.Success;
} }
var contentResult = await _utility.GetEventNotificationMentions( var contentResult = await _utility.GetEventNotificationMentions(
scheduledEvent, data, ct); scheduledEvent, data, ct);
if (!contentResult.IsDefined(out var content)) if (!contentResult.IsDefined(out var content))
{ {
return Result.FromError(contentResult); return ResultExtensions.FromError(contentResult);
} }
var earlyResult = new EmbedBuilder() var earlyResult = new EmbedBuilder()

View file

@ -4,7 +4,7 @@ using Remora.Discord.API.Gateway.Commands;
using Remora.Discord.API.Objects; using Remora.Discord.API.Objects;
using Remora.Discord.Gateway; using Remora.Discord.Gateway;
namespace Octobot.Services.Update; namespace TeamOctolings.Octobot.Services.Update;
public sealed class SongUpdateService : BackgroundService public sealed class SongUpdateService : BackgroundService
{ {
@ -36,6 +36,11 @@ public sealed class SongUpdateService : BackgroundService
("Deep Cut", "Fins in the Air", new TimeSpan(0, 3, 1)) ("Deep Cut", "Fins in the Air", new TimeSpan(0, 3, 1))
]; ];
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 List<Activity> _activityList = [new Activity("with Remora.Discord", ActivityType.Game)];
private readonly DiscordGatewayClient _client; private readonly DiscordGatewayClient _client;
@ -58,19 +63,33 @@ public sealed class SongUpdateService : BackgroundService
while (!ct.IsCancellationRequested) while (!ct.IsCancellationRequested)
{ {
var nextSong = SongList[_nextSongIndex]; var nextSong = NextSong();
_activityList[0] = new Activity($"{nextSong.Name} / {nextSong.Author}", _activityList[0] = new Activity($"{nextSong.Name} / {nextSong.Author}",
ActivityType.Listening); ActivityType.Listening);
_client.SubmitCommand( _client.SubmitCommand(
new UpdatePresence( new UpdatePresence(
UserStatus.Online, false, DateTimeOffset.UtcNow, _activityList)); 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++; _nextSongIndex++;
if (_nextSongIndex >= SongList.Length) if (_nextSongIndex >= SongList.Length)
{ {
_nextSongIndex = 0; _nextSongIndex = 0;
} }
await Task.Delay(nextSong.Duration, ct); return nextSong;
}
} }
} }

View file

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
@ -16,29 +16,31 @@
<Company>TeamOctolings</Company> <Company>TeamOctolings</Company>
<NeutralLanguage>en</NeutralLanguage> <NeutralLanguage>en</NeutralLanguage>
<Description>A general-purpose Discord bot for moderation written in C#</Description> <Description>A general-purpose Discord bot for moderation written in C#</Description>
<ApplicationIcon>docs/octobot.ico</ApplicationIcon> <ApplicationIcon>../docs/octobot.ico</ApplicationIcon>
<GitVersion>false</GitVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="DiffPlex" Version="1.7.2" /> <PackageReference Include="DiffPlex" Version="1.7.2" />
<PackageReference Include="GitInfo" Version="3.3.5" />
<PackageReference Include="Humanizer.Core.ru" Version="2.14.1" /> <PackageReference Include="Humanizer.Core.ru" Version="2.14.1" />
<PackageReference Include="JetBrains.Annotations" Version="2023.3.0" /> <PackageReference Include="JetBrains.Annotations" Version="2023.3.0" />
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4" /> <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Remora.Commands" Version="10.0.5" /> <PackageReference Include="Remora.Commands" Version="10.0.5" />
<PackageReference Include="Remora.Discord.Caching" Version="38.0.1" /> <PackageReference Include="Remora.Discord.Caching" Version="39.0.0" />
<PackageReference Include="Remora.Discord.Extensions" Version="5.3.4" /> <PackageReference Include="Remora.Discord.Extensions" Version="5.3.5" />
<PackageReference Include="Remora.Discord.Hosting" Version="6.0.9" /> <PackageReference Include="Remora.Discord.Hosting" Version="6.0.10" />
<PackageReference Include="Remora.Discord.Interactivity" Version="4.5.3" /> <PackageReference Include="Remora.Discord.Interactivity" Version="4.5.4" />
<PackageReference Include="Serilog.Extensions.Logging.File" Version="3.0.0" /> <PackageReference Include="Serilog.Extensions.Logging.File" Version="3.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<EmbeddedResource Update="locale\Messages.resx"> <EmbeddedResource Update="Messages.resx">
<Generator>ResXFileCodeGenerator</Generator> <Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Messages.Designer.cs</LastGenOutput> <LastGenOutput>Messages.Designer.cs</LastGenOutput>
</EmbeddedResource> </EmbeddedResource>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<AdditionalFiles Include="CodeAnalysis\BannedSymbols.txt" /> <AdditionalFiles Include="..\CodeAnalysis\BannedSymbols.txt" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -1,16 +1,19 @@
using System.Drawing; using System.Drawing;
using System.Text; using System.Text;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using Octobot.Data; using Microsoft.Extensions.Logging;
using Octobot.Extensions;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects;
using Remora.Discord.Extensions.Embeds; using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting; using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core; using Remora.Rest.Core;
using Remora.Results; using Remora.Results;
using TeamOctolings.Octobot.Attributes;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
namespace Octobot.Services; namespace TeamOctolings.Octobot;
/// <summary> /// <summary>
/// Provides utility methods that cannot be transformed to extension methods because they require usage /// Provides utility methods that cannot be transformed to extension methods because they require usage
@ -18,133 +21,23 @@ namespace Octobot.Services;
/// </summary> /// </summary>
public sealed class Utility 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 IDiscordRestChannelAPI _channelApi;
private readonly IDiscordRestGuildScheduledEventAPI _eventApi; private readonly IDiscordRestGuildScheduledEventAPI _eventApi;
private readonly IDiscordRestGuildAPI _guildApi; private readonly IDiscordRestGuildAPI _guildApi;
private readonly IDiscordRestUserAPI _userApi;
public Utility( public Utility(
IDiscordRestChannelAPI channelApi, IDiscordRestGuildScheduledEventAPI eventApi, IDiscordRestGuildAPI guildApi, IDiscordRestChannelAPI channelApi, IDiscordRestGuildScheduledEventAPI eventApi, IDiscordRestGuildAPI guildApi)
IDiscordRestUserAPI userApi)
{ {
_channelApi = channelApi; _channelApi = channelApi;
_eventApi = eventApi; _eventApi = eventApi;
_guildApi = guildApi; _guildApi = guildApi;
_userApi = userApi;
} }
/// <summary> [StaticCallersOnly]
/// Checks whether or not a member can interact with another member public static ILogger<Program>? StaticLogger { get; set; }
/// </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 botResult = await _userApi.GetCurrentUserAsync(ct);
if (!botResult.IsDefined(out var bot))
{
return Result<string?>.FromError(botResult);
}
var guildResult = await _guildApi.GetGuildAsync(guildId, ct: ct);
if (!guildResult.IsDefined(out var guild))
{
return Result<string?>.FromError(guildResult);
}
var targetMemberResult = await _guildApi.GetGuildMemberAsync(guildId, targetId, ct);
if (!targetMemberResult.IsDefined(out var targetMember))
{
return Result<string?>.FromSuccess(null);
}
var currentMemberResult = await _guildApi.GetGuildMemberAsync(guildId, bot.ID, ct);
if (!currentMemberResult.IsDefined(out var currentMember))
{
return Result<string?>.FromError(currentMemberResult);
}
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, currentMember, currentMember);
}
var interacterResult = await _guildApi.GetGuildMemberAsync(guildId, interacterId.Value, ct);
return interacterResult.IsDefined(out var interacter)
? CheckInteractions(action, guild, roles, targetMember, currentMember, interacter)
: Result<string?>.FromError(interacterResult);
}
private static Result<string?> CheckInteractions(
string action, IGuild guild, IReadOnlyList<IRole> roles, IGuildMember targetMember, IGuildMember currentMember,
IGuildMember interacter)
{
if (!targetMember.User.IsDefined(out var targetUser))
{
return new ArgumentNullError(nameof(targetMember.User));
}
if (!interacter.User.IsDefined(out var interacterUser))
{
return new ArgumentNullError(nameof(interacter.User));
}
if (currentMember.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 => currentMember.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());
}
if (interacterUser.ID == guild.OwnerID)
{
return Result<string?>.FromSuccess(null);
}
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());
}
/// <summary> /// <summary>
/// Gets the string mentioning the <see cref="GuildSettings.EventNotificationRole" /> and event subscribers related to /// Gets the string mentioning the <see cref="GuildSettings.EventNotificationRole" /> and event subscribers related to
@ -232,7 +125,7 @@ public sealed class Utility
} }
} }
public async Task<Result<Snowflake>> GetEmergencyFeedbackChannel(IGuild guild, GuildData data, CancellationToken ct) public async Task<Result<Snowflake>> GetEmergencyFeedbackChannel(IGuild guild, GuildData data, CancellationToken ct = default)
{ {
var privateFeedback = GuildSettings.PrivateFeedbackChannel.Get(data.Settings); var privateFeedback = GuildSettings.PrivateFeedbackChannel.Get(data.Settings);
if (!privateFeedback.Empty()) if (!privateFeedback.Empty())

View file

@ -15,23 +15,16 @@ Veemo! I'm a general-purpose bot for moderation (formerly known as Boyfriend) wr
* Reminding everyone about that new event you made * Reminding everyone about that new event you made
* Renaming those annoying self-hoisting members * Renaming those annoying self-hoisting members
* Log everything from joining the server to deleting messages * Log everything from joining the server to deleting messages
* Listen to music! * Listen to Inkantation!
*...a-a-and more!* *...a-a-and more!*
## Building Octobot ## Building Octobot
1. Install [.NET 8 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) Check out the Octobot's Wiki for details.
2. Go to the [Discord Developer Portal](https://discord.com/developers), create a new application and get a bot token. Don't forget to also enable all intents!
3. Clone this repository and open `Octobot` folder. | [Windows](https://github.com/TeamOctolings/Octobot/wiki/Installing-Windows) | [Linux/macOS](https://github.com/TeamOctolings/Octobot/wiki/Installing-Unix) |
``` | --- | --- |
git clone https://github.com/TeamOctolings/Octobot
cd Octobot
```
4. Run Octobot using `dotnet` with `BOT_TOKEN` variable.
```
dotnet run BOT_TOKEN='ENTER_TOKEN_HERE'
```
## Contributing ## Contributing

View file

@ -1,657 +0,0 @@
<?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="Sound1" xml:space="preserve">
<value>вииимо!</value>
</data>
<data name="Sound2" xml:space="preserve">
<value>вуууми!</value>
</data>
<data name="Sound3" xml:space="preserve">
<value>нгьес!</value>
</data>
<data name="YouWereBanned" 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 дней таймаут не разрешает, вот настроишь роль мута, тогда поговорим</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="UserCannotModerateMembers" 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 уважаемого (я забываю о шизоидах если они ливнули минимум месяц назад)</value>
</data>
<data name="SettingsDefaultRole" xml:space="preserve">
<value>дефолтное звание</value>
</data>
<data name="SettingsPrivateFeedbackChannel" xml:space="preserve">
<value>канал для секретных уведомлений</value>
</data>
<data name="SettingsPublicFeedbackChannel" 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="UserUnmuted" xml:space="preserve">
<value>{0} в размуте</value>
</data>
<data name="UserNotMuted" xml:space="preserve">
<value>этого шизоида никто не мутил.</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="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@mctaylors" xml:space="preserve">
<value>скучный девелопер + дизайнер создавший Octobot's Wiki</value>
</data>
<data name="AboutDeveloper@neroduckale" xml:space="preserve">
<value>ВАЖНЫЙ соучастник кодинг-стримов @Octol1ttle</value>
</data>
<data name="AboutDeveloper@Octol1ttle" xml:space="preserve">
<value>САМЫЙ ВАЖНЫЙ чел написавший кода больше всех (99.99%)</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>если я был бы html, я бы сказал 404</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="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>100% да</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>
</root>